33 Commits

Author SHA1 Message Date
kris
a5e8ba2b7e chore: publish wechat forwarding release v2.4.0 2026-03-28 09:00:53 +08:00
kris
cc08ca28aa fix: exercise native chat actions via ui callbacks 2026-03-28 08:54:44 +08:00
kris
a3a7f43626 test: verify native chat mode transitions 2026-03-28 08:50:57 +08:00
kris
64ad401d0c test: cover native chat chrome transitions 2026-03-28 08:47:55 +08:00
kris
d2291af32c fix: stabilize native chat selection chrome 2026-03-28 08:45:20 +08:00
kris
7109f1d3db feat: add wechat style native message forwarding 2026-03-28 08:39:08 +08:00
kris
200fc18210 test: cover native forward request boundary 2026-03-28 08:33:08 +08:00
kris
13c67425ab refactor: isolate forward payload serialization 2026-03-28 08:29:05 +08:00
kris
0783f4da14 fix: serialize forward target payloads 2026-03-28 08:21:37 +08:00
kris
42063db78f fix: tighten chat selection state invariants 2026-03-28 08:18:22 +08:00
kris
c90dea4b7c feat: add native forward target picker 2026-03-28 07:22:48 +08:00
kris
9613c3c154 feat: add structured message forwarding payloads 2026-03-28 07:20:49 +08:00
kris
227d270505 feat: add native chat forward selection state 2026-03-28 07:18:58 +08:00
kris
b606af66f6 docs: add wechat forwarding implementation plan 2026-03-28 07:12:15 +08:00
kris
a9e8bb9ddd docs: add wechat message forwarding spec 2026-03-28 07:08:05 +08:00
kris
f0735b31e5 feat: restore wechat thread ui and group chat 2026-03-28 05:21:44 +08:00
kris
afa7e79ad2 docs: add wechat ui restore implementation plan 2026-03-28 02:09:29 +08:00
kris
e27ea1e071 docs: add thread and group chat ui restore spec 2026-03-28 02:04:10 +08:00
kris
0a3390b132 chore: publish native ui polish release v2.2.1 2026-03-27 15:58:50 +08:00
kris
4dbf4ac1de feat: polish native root tab memory 2026-03-27 15:41:26 +08:00
kris
6559ad5bce feat: add native ota progress feedback 2026-03-27 15:39:19 +08:00
kris
ae571a76ff feat: polish chat composer feedback 2026-03-27 14:26:57 +08:00
kris
63ceef9871 docs: add native ui phase 2 spec and plan 2026-03-27 14:22:58 +08:00
kris
8da592bddf chore: publish wechat native rollback release v2.2.0 2026-03-27 13:41:23 +08:00
kris
9e0b5b223f android: preserve device detail summary context 2026-03-27 13:28:15 +08:00
kris
ff56617fdb android: simplify wechat device and me surfaces 2026-03-27 13:20:46 +08:00
kris
05e26afbf1 Restore chat-first project detail surface 2026-03-27 02:14:30 +08:00
kris
b794ba05fa fix: preserve abnormal device status in root list 2026-03-27 02:07:05 +08:00
kris
ce8dcad41c feat: restore wechat-style root shell 2026-03-27 01:58:34 +08:00
kris
17300c49ea fix: tighten wechat surface mapper contract 2026-03-27 01:52:29 +08:00
kris
efcefd8a62 test: freeze wechat surface contract 2026-03-27 01:49:01 +08:00
kris
785db90a7a docs: add wechat native ui rollback plan 2026-03-27 01:47:28 +08:00
kris
8439428479 docs: add wechat native ui rollback spec 2026-03-27 01:42:05 +08:00
81 changed files with 11669 additions and 1017 deletions

View File

@@ -33,7 +33,7 @@
- `src/boss_control`:空占位目录,不参与当前运行
- `src/boss_device_agent`:空占位目录,不参与当前运行
## 当前运行状态2026-03-26
## 当前运行状态2026-03-28
本地:
@@ -90,13 +90,23 @@ Android APK
- 已生成 Android debug APK`android/app/build/outputs/apk/debug/app-debug.apk`
- 已生成 Android signed release APK`android/app/build/outputs/apk/release/app-release.apk`
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.1.1``versionCode=8`
- 当前最新 release 构建版本:`2.4.0``versionCode=12`
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
- 当前聊天列表已切到“线程 = 会话窗口”的结构:主标题显示线程名,副标题显示所属文件夹名,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会显示成多个独立聊天窗口
- 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
- 当前 `设备``我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的``审计对话` 作为置顶会话保留在会话首页
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API不再打开 WebView
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen``AppCompatActivity` 不兼容导致的启动闪退
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
- `2.1.1` 已补上原生 OTA 下载链路:关于页会直接请求受保护的 `/api/v1/user/ota/package`,下载完成后可拉起系统安装器
- `2.2.0` 已把原生 UI 从控制台风格回退到微信式简单列表和聊天优先视图,并复核了设备页 / 我的页 / 深层高级入口
- `2.2.1` 已继续补齐原生交互细节:聊天页发送后会先出现本地“发送中”气泡,关于页会展示 OTA 下载进度 / 重试 / 安装授权提示,根 tab 会记住用户上次停留位置并改成“再按一次返回进入后台”
- `2.3.0` 已把会话模型切到“线程 = 聊天窗口”,补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
- `2.4.0` 已把消息转发切到微信式原生链路:聊天页支持长按消息操作、多选合并转发、统一目标会话选择页;单条消息转发显示为普通转发消息,多条消息转发显示为“聊天记录”卡片
## 本地启动
@@ -180,6 +190,8 @@ device-agent 当前职责:
- Android 原生会话页:`android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Android 原生设备页:`android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
- Android 原生我的页:`android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java``android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java``android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
- Android 微信式 surface contract`android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- Android 聊天页布局:`android/app/src/main/res/layout/activity_project_chat.xml`
- 服务器环境示例:`.env.server.example`
当前 `scripts/deploy-server.sh`
@@ -244,6 +256,8 @@ npm run aab:release
- Web 端根布局当前仍保留 `NativeAppBridge`,用于浏览器态与历史桥接兼容;当前正式 APK 已改为原生 Activity + 原生 API 客户端,不再依赖 WebView
- APP 日志桥已经改成会话感知:只会按当前登录账号解析绑定设备,不再在未登录页默认按全局管理员设备写日志
- APP 外壳已经从“桌面预览卡片”切回真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部 `会话 / 设备 / 我的` 导航固定在视口底部,背景改为全屏 cover不再出现圆角矩形外壳
- 原生 Android 当前也和这套产品口径对齐:根页采用微信式简单列表,项目聊天页改成消息流优先,`设备 / 我的` 页不再展示控制台式统计卡片
- 原生聊天页当前会即时渲染本地发送中消息,并且只有在用户接近底部或本次发送是主动触发时才自动滚到底
- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页”
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill并支持一键复制调用语句
@@ -259,6 +273,9 @@ npm run aab:release
- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
- 当前默认最高管理员账号:`17600003315`
- 当前默认测试密码:`boss123456`
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`

View File

@@ -13,6 +13,9 @@ android {
buildFeatures {
buildConfig true
}
testOptions {
unitTests.includeAndroidResources = true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
@@ -33,8 +36,8 @@ android {
applicationId "com.hyzq.boss"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 8
versionName "2.1.1"
versionCode 12
versionName "2.4.0"
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -56,6 +59,7 @@ dependencies {
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
testImplementation "junit:junit:$junitVersion"
testImplementation "org.robolectric:robolectric:4.14.1"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
}

View File

@@ -31,7 +31,11 @@
<activity android:name=".ProjectGoalsActivity" android:exported="false" />
<activity android:name=".ProjectVersionsActivity" android:exported="false" />
<activity android:name=".ProjectForwardActivity" android:exported="false" />
<activity android:name=".ForwardTargetActivity" android:exported="false" />
<activity android:name=".ThreadDetailActivity" android:exported="false" />
<activity android:name=".ConversationInfoActivity" android:exported="false" />
<activity android:name=".GroupInfoActivity" android:exported="false" />
<activity android:name=".GroupCreateActivity" android:exported="false" />
<activity android:name=".DeviceDetailActivity" android:exported="false" />
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" />
<activity android:name=".SkillInventoryActivity" android:exported="false" />

View File

@@ -9,8 +9,9 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
@@ -19,8 +20,34 @@ import org.json.JSONArray;
import org.json.JSONObject;
public class AboutActivity extends BossScreenActivity {
private static final long OTA_PROGRESS_POLL_INTERVAL_MS = 1_000L;
private static final String OTA_UI_PREFS = "boss_native_client";
private static final String KEY_ACTIVE_DOWNLOAD_ID = "ota_active_download_id";
private static final String KEY_COMPLETED_DOWNLOAD_ID = "ota_completed_download_id";
private static final String KEY_LAST_DOWNLOAD_FILE_NAME = "ota_last_download_file_name";
private static final String KEY_LAST_DOWNLOAD_VERSION = "ota_last_download_version";
private static final String KEY_LAST_DOWNLOAD_STATUS = "ota_last_download_status";
private long activeDownloadId = -1L;
private long completedDownloadId = -1L;
private @Nullable JSONObject otaPayload;
private @Nullable LinearLayout otaDownloadStateSection;
private @Nullable Uri downloadedApkUri;
private @Nullable String lastDownloadFileName;
private @Nullable String lastDownloadVersion;
private int lastDownloadStatus = -1;
private long lastDownloadedBytes = 0L;
private long lastTotalBytes = -1L;
private final Handler otaProgressHandler = new Handler(Looper.getMainLooper());
private final Runnable otaProgressPoller = new Runnable() {
@Override
public void run() {
refreshDownloadStateSection();
if (activeDownloadId > 0) {
otaProgressHandler.postDelayed(this, OTA_PROGRESS_POLL_INTERVAL_MS);
}
}
};
private final BroadcastReceiver otaDownloadReceiver = new BroadcastReceiver() {
@Override
@@ -32,7 +59,6 @@ public class AboutActivity extends BossScreenActivity {
if (downloadId <= 0 || downloadId != activeDownloadId) {
return;
}
activeDownloadId = -1L;
handleCompletedDownload(downloadId);
}
};
@@ -40,7 +66,8 @@ public class AboutActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("关于 / OTA", "原生版本中心");
configureScreen("关于", "版本与 OTA 更新");
restoreDownloadUiState();
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
@@ -52,6 +79,7 @@ public class AboutActivity extends BossScreenActivity {
@Override
protected void onDestroy() {
otaProgressHandler.removeCallbacks(otaProgressPoller);
try {
unregisterReceiver(otaDownloadReceiver);
} catch (IllegalArgumentException ignored) {
@@ -67,14 +95,12 @@ public class AboutActivity extends BossScreenActivity {
try {
BossApiClient.ApiResponse settings = apiClient.getSettings();
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
BossApiClient.ApiResponse session = apiClient.getSession();
if (!settings.ok() || !ota.ok() || !session.ok()) {
if (!settings.ok() || !ota.ok()) {
throw new IllegalStateException("PROFILE_OR_OTA_LOAD_FAILED");
}
runOnUiThread(() -> renderAbout(
settings.json.optJSONObject("user"),
ota.json,
session.json.optJSONObject("session")
ota.json
));
} catch (Exception error) {
runOnUiThread(() -> {
@@ -85,69 +111,164 @@ public class AboutActivity extends BossScreenActivity {
});
}
private void renderAbout(@Nullable JSONObject user, JSONObject ota, @Nullable JSONObject session) {
private void renderAbout(@Nullable JSONObject user, JSONObject ota) {
replaceContent();
otaPayload = ota;
if (user != null) {
appendContent(BossUi.buildCard(
this,
"当前版本",
user.optString("version", "-")
+ "\n当前账号" + user.optString("account", "-")
+ "\n绑定 Codex" + user.optString("boundCodexNodeLabel", "未绑定"),
session == null ? "-" : "会话到期 " + session.optString("expiresAt", "-")
));
}
JSONObject availableRelease = ota.optJSONObject("availableRelease");
String otaBody = availableRelease == null
? "当前已经是最新版本。"
: availableRelease.optString("version", "未知版本")
+ "\n" + availableRelease.optString("summary", "暂无摘要")
+ "\n文件" + availableRelease.optString("packageFileName", "-");
appendContent(BossUi.buildCard(
invalidateStaleDownloadedApk(ota.optJSONObject("availableRelease"));
appendContent(BossUi.buildWechatMenuRow(
this,
"OTA 状态",
otaBody,
"当前版本 " + ota.optString("currentVersion", "-")
"当前版本",
user == null ? ota.optString("currentVersion", "-") : user.optString("version", ota.optString("currentVersion", "-")),
"已安装版本",
null,
null
));
LinearLayout actionCard = BossUi.buildCard(this, "OTA 操作", "可在原生页直接检查更新、登记 OTA 并下载 APK。", "当前接口:/api/v1/user/ota");
Button check = BossUi.buildPrimaryButton(this, "检查更新");
check.setOnClickListener(v -> performOtaAction("check"));
actionCard.addView(check);
Button apply = BossUi.buildSecondaryButton(this, "登记应用 OTA");
apply.setOnClickListener(v -> performOtaAction("apply"));
actionCard.addView(apply);
Button download = BossUi.buildSecondaryButton(this, "应用内下载 APK");
download.setOnClickListener(v -> downloadLatestApk());
actionCard.addView(download);
appendContent(actionCard);
JSONObject availableRelease = ota.optJSONObject("availableRelease");
appendContent(BossUi.buildWechatMenuRow(
this,
"OTA 状态",
buildOtaStatusSubtitle(ota),
buildOtaStatusMeta(ota),
availableRelease == null ? null : "OTA",
null
));
JSONArray logs = ota.optJSONArray("logs");
if (logs != null) {
for (int i = 0; i < logs.length(); i++) {
JSONObject log = logs.optJSONObject(i);
if (log == null) continue;
appendContent(BossUi.buildCard(
this,
log.optString("version", "OTA"),
log.optString("summary", ""),
log.optString("status", "-") + " · " + log.optString("createdAt", "-")
));
appendContent(BossUi.buildSoftPanel(
this,
"OTA 更新内容",
buildOtaContentBody(ota),
availableRelease == null ? "没有可下载的新版本时,可直接点按钮检查更新。" : "下载完成后会自动拉起系统安装器。"
));
android.widget.Button otaButton = BossUi.buildPrimaryButton(this, resolvePrimaryOtaActionLabel(availableRelease));
otaButton.setEnabled(activeDownloadId <= 0);
otaButton.setOnClickListener(v -> performPrimaryOtaAction(availableRelease));
appendContent(otaButton);
appendContent(BossUi.buildMenuRow(this, "重新检查更新", "拉取最新 OTA 状态", null, v -> performOtaAction("check")));
if (downloadedApkUri != null || completedDownloadId > 0) {
appendContent(BossUi.buildMenuRow(
this,
"同步已应用状态",
"安装完成后点这里,把服务端 OTA 状态更新为已应用",
null,
v -> performOtaAction("apply")
));
}
otaDownloadStateSection = new LinearLayout(this);
otaDownloadStateSection.setOrientation(LinearLayout.VERTICAL);
appendContent(otaDownloadStateSection);
refreshDownloadStateSection();
setRefreshing(false);
}
private static String buildOtaStatusSubtitle(JSONObject ota) {
JSONObject availableRelease = ota.optJSONObject("availableRelease");
if (availableRelease == null) {
return "当前已经是最新版本。";
}
return "发现新版本 " + availableRelease.optString("version", "未知版本");
}
private static String buildOtaStatusMeta(JSONObject ota) {
JSONObject availableRelease = ota.optJSONObject("availableRelease");
if (availableRelease == null) {
return "当前版本 " + ota.optString("currentVersion", "-");
}
String summaryLine = firstSummaryLine(availableRelease.optJSONArray("summary"));
return availableRelease.optString("packageFileName", "boss-android-latest.apk")
+ (summaryLine.isEmpty() ? "" : " · " + summaryLine);
}
private static String buildOtaContentBody(JSONObject ota) {
JSONObject availableRelease = ota.optJSONObject("availableRelease");
if (availableRelease != null) {
JSONArray lines = availableRelease.optJSONArray("summary");
if (lines != null && lines.length() > 0) {
StringBuilder builder = new StringBuilder("版本 ").append(availableRelease.optString("version", "-"));
for (int i = 0; i < lines.length(); i++) {
String line = lines.optString(i);
if (line == null || line.isEmpty()) {
continue;
}
builder.append("\n").append(i + 1).append(". ").append(line);
}
return builder.toString();
}
String note = availableRelease.optString("note", "");
if (!note.isEmpty()) {
return "版本 " + availableRelease.optString("version", "-") + "\n" + note;
}
}
setRefreshing(false);
JSONArray logs = ota.optJSONArray("logs");
if (logs != null && logs.length() > 0) {
JSONObject latest = logs.optJSONObject(0);
if (latest != null) {
String note = latest.optString("note", "");
if (!note.isEmpty()) {
return latest.optString("version", "当前版本") + "\n" + note;
}
}
}
return "当前没有待更新内容,点击下方按钮可重新检查更新。";
}
private static String firstSummaryLine(@Nullable JSONArray lines) {
if (lines == null || lines.length() == 0) {
return "";
}
for (int i = 0; i < lines.length(); i++) {
String line = lines.optString(i);
if (line != null && !line.isEmpty()) {
return line;
}
}
return "";
}
private String resolvePrimaryOtaActionLabel(@Nullable JSONObject availableRelease) {
if (activeDownloadId > 0) {
return "下载中…";
}
if (downloadedApkUri != null) {
return "安装更新";
}
if (availableRelease != null) {
return "立即 OTA";
}
return "检查更新";
}
private void performPrimaryOtaAction(@Nullable JSONObject availableRelease) {
if (activeDownloadId > 0) {
return;
}
if (downloadedApkUri != null) {
installDownloadedApk();
return;
}
if (availableRelease != null) {
downloadLatestApk();
return;
}
performOtaAction("check");
}
private void performOtaAction(String action) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = "check".equals(action) ? apiClient.checkOta() : apiClient.applyOta();
BossApiClient.ApiResponse response = "apply".equals(action)
? apiClient.applyOta()
: apiClient.checkOta();
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("check".equals(action) ? "已完成版本检查" : "已登记 OTA 应用");
if ("apply".equals(action)) {
clearLocalOtaDownloadState();
}
showMessage("apply".equals(action) ? "已同步 OTA 应用状态" : "已完成版本检查");
reload();
});
} catch (Exception error) {
@@ -159,6 +280,19 @@ public class AboutActivity extends BossScreenActivity {
});
}
private void clearLocalOtaDownloadState() {
activeDownloadId = -1L;
completedDownloadId = -1L;
downloadedApkUri = null;
lastDownloadFileName = null;
lastDownloadVersion = null;
lastDownloadStatus = -1;
lastDownloadedBytes = 0L;
lastTotalBytes = -1L;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
}
private void downloadLatestApk() {
executor.execute(() -> {
try {
@@ -187,6 +321,9 @@ public class AboutActivity extends BossScreenActivity {
String fileName = availableRelease == null
? "boss-android-latest.apk"
: availableRelease.optString("packageFileName", "boss-android-latest.apk");
String releaseVersion = availableRelease == null
? null
: availableRelease.optString("version", null);
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(apiClient.getProtectedOtaPackageUrl()));
request.setTitle(fileName);
@@ -198,7 +335,18 @@ public class AboutActivity extends BossScreenActivity {
request.addRequestHeader("x-boss-native-app", "1");
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);
downloadedApkUri = null;
lastDownloadFileName = fileName;
lastDownloadVersion = releaseVersion;
lastDownloadStatus = DownloadManager.STATUS_PENDING;
lastDownloadedBytes = 0L;
lastTotalBytes = -1L;
completedDownloadId = -1L;
activeDownloadId = manager.enqueue(request);
persistDownloadUiState();
otaProgressHandler.removeCallbacks(otaProgressPoller);
otaProgressHandler.post(otaProgressPoller);
refreshDownloadStateSection();
showMessage("已开始下载,完成后会自动拉起安装。");
}
@@ -213,10 +361,23 @@ public class AboutActivity extends BossScreenActivity {
try (android.database.Cursor cursor = manager.query(query)) {
if (cursor == null || !cursor.moveToFirst()) {
showMessage("下载完成,但无法读取文件信息");
activeDownloadId = -1L;
completedDownloadId = -1L;
lastDownloadStatus = DownloadManager.STATUS_FAILED;
persistDownloadUiState();
refreshDownloadStateSection();
return;
}
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
if (status != DownloadManager.STATUS_SUCCESSFUL) {
lastDownloadStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
lastDownloadedBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
lastTotalBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
if (lastDownloadStatus != DownloadManager.STATUS_SUCCESSFUL) {
activeDownloadId = -1L;
completedDownloadId = -1L;
downloadedApkUri = null;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
showMessage("下载未成功完成");
return;
}
@@ -224,10 +385,23 @@ public class AboutActivity extends BossScreenActivity {
Uri apkUri = manager.getUriForDownloadedFile(downloadId);
if (apkUri == null) {
activeDownloadId = -1L;
completedDownloadId = -1L;
lastDownloadStatus = DownloadManager.STATUS_FAILED;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
showMessage("下载完成,但找不到安装包");
return;
}
activeDownloadId = -1L;
completedDownloadId = downloadId;
downloadedApkUri = apkUri;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
if (!getPackageManager().canRequestPackageInstalls()) {
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
@@ -241,4 +415,262 @@ public class AboutActivity extends BossScreenActivity {
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(installIntent);
}
private void refreshDownloadStateSection() {
if (otaDownloadStateSection == null) {
return;
}
otaDownloadStateSection.removeAllViews();
OtaDownloadStateMapper.UiState uiState = resolveDownloadUiState();
if (uiState == null) {
return;
}
otaDownloadStateSection.addView(BossUi.buildListRow(
this,
uiState.title,
uiState.subtitle,
uiState.meta,
uiState.badge,
null
));
if (uiState.actionKind != OtaDownloadStateMapper.ActionKind.NONE) {
otaDownloadStateSection.addView(BossUi.buildMenuRow(
this,
uiState.actionLabel,
uiState.subtitle,
null,
v -> performDownloadStateAction(uiState.actionKind)
));
}
}
@Nullable
private OtaDownloadStateMapper.UiState resolveDownloadUiState() {
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
invalidateStaleDownloadedApk(availableRelease);
String fileName = resolveDownloadFileName();
if (activeDownloadId > 0) {
DownloadProgressSnapshot snapshot = queryDownloadProgress(activeDownloadId);
if (snapshot != null) {
lastDownloadStatus = snapshot.status;
lastDownloadedBytes = snapshot.bytesDownloaded;
lastTotalBytes = snapshot.totalBytes;
boolean hasKnownTotal = snapshot.totalBytes > 0;
int percent = hasKnownTotal
? (int) Math.round((snapshot.bytesDownloaded * 100.0d) / snapshot.totalBytes)
: 0;
if (snapshot.status == DownloadManager.STATUS_RUNNING
|| snapshot.status == DownloadManager.STATUS_PENDING
|| snapshot.status == DownloadManager.STATUS_PAUSED) {
return OtaDownloadStateMapper.active(fileName, percent, hasKnownTotal, snapshot.bytesDownloaded, snapshot.totalBytes);
}
if (snapshot.status == DownloadManager.STATUS_FAILED) {
activeDownloadId = -1L;
completedDownloadId = -1L;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
return OtaDownloadStateMapper.failed(fileName);
}
}
}
if (lastDownloadStatus == DownloadManager.STATUS_FAILED) {
return OtaDownloadStateMapper.failed(fileName);
}
if (downloadedApkUri != null) {
if (!getPackageManager().canRequestPackageInstalls()) {
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
}
return OtaDownloadStateMapper.readyToInstall(fileName);
}
return null;
}
private void performDownloadStateAction(OtaDownloadStateMapper.ActionKind actionKind) {
switch (actionKind) {
case RETRY_DOWNLOAD:
downloadLatestApk();
break;
case OPEN_INSTALL_PERMISSION:
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
break;
case INSTALL_APK:
installDownloadedApk();
break;
case NONE:
default:
break;
}
}
private void installDownloadedApk() {
if (downloadedApkUri == null) {
showMessage("当前没有可安装的更新包");
return;
}
if (!getPackageManager().canRequestPackageInstalls()) {
showMessage("请先开启安装未知来源应用权限");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
return;
}
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.setDataAndType(downloadedApkUri, "application/vnd.android.package-archive");
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(installIntent);
}
private String resolveDownloadFileName() {
if (lastDownloadFileName != null && !lastDownloadFileName.isEmpty()) {
return lastDownloadFileName;
}
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
if (availableRelease != null) {
return availableRelease.optString("packageFileName", "boss-android-latest.apk");
}
return "boss-android-latest.apk";
}
private void restoreDownloadUiState() {
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);
completedDownloadId = prefs.getLong(KEY_COMPLETED_DOWNLOAD_ID, -1L);
lastDownloadFileName = prefs.getString(KEY_LAST_DOWNLOAD_FILE_NAME, null);
lastDownloadVersion = prefs.getString(KEY_LAST_DOWNLOAD_VERSION, null);
lastDownloadStatus = prefs.getInt(KEY_LAST_DOWNLOAD_STATUS, -1);
if (completedDownloadId > 0) {
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager != null) {
downloadedApkUri = manager.getUriForDownloadedFile(completedDownloadId);
}
}
if (activeDownloadId > 0) {
otaProgressHandler.removeCallbacks(otaProgressPoller);
otaProgressHandler.post(otaProgressPoller);
}
}
private void persistDownloadUiState() {
getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_ACTIVE_DOWNLOAD_ID, activeDownloadId)
.putLong(KEY_COMPLETED_DOWNLOAD_ID, completedDownloadId)
.putString(KEY_LAST_DOWNLOAD_FILE_NAME, lastDownloadFileName)
.putString(KEY_LAST_DOWNLOAD_VERSION, lastDownloadVersion)
.putInt(KEY_LAST_DOWNLOAD_STATUS, lastDownloadStatus)
.apply();
}
private void invalidateStaleDownloadedApk(@Nullable JSONObject availableRelease) {
long[] downloadIds = collectStaleDownloadIdsForRemoval(
availableRelease,
lastDownloadFileName,
lastDownloadVersion,
downloadedApkUri != null || completedDownloadId > 0 || activeDownloadId > 0,
activeDownloadId,
completedDownloadId
);
if (downloadIds.length == 0) {
return;
}
removeStaleDownloadTasks(downloadIds);
clearLocalOtaDownloadState();
}
private void removeStaleDownloadTasks(long[] downloadIds) {
if (downloadIds.length == 0) {
return;
}
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager == null) {
return;
}
try {
manager.remove(downloadIds);
} catch (RuntimeException ignored) {
// Keep UI state recoverable even if DownloadManager cleanup fails.
}
}
static long[] collectStaleDownloadIdsForRemoval(
@Nullable JSONObject availableRelease,
@Nullable String downloadedFileName,
@Nullable String downloadedVersion,
boolean hasLocalDownload,
long activeId,
long completedId
) {
if (!hasLocalDownload) {
return new long[0];
}
if (isDownloadedReleaseCurrent(availableRelease, downloadedFileName, downloadedVersion)) {
return new long[0];
}
return collectDownloadIdsForRemoval(activeId, completedId);
}
private static long[] collectDownloadIdsForRemoval(long activeId, long completedId) {
if (activeId > 0 && completedId > 0) {
if (activeId == completedId) {
return new long[]{activeId};
}
return new long[]{activeId, completedId};
}
if (activeId > 0) {
return new long[]{activeId};
}
if (completedId > 0) {
return new long[]{completedId};
}
return new long[0];
}
private static boolean isDownloadedReleaseCurrent(
@Nullable JSONObject availableRelease,
@Nullable String downloadedFileName,
@Nullable String downloadedVersion
) {
if (availableRelease == null) {
return false;
}
String releaseFileName = availableRelease.optString("packageFileName", "");
String releaseVersion = availableRelease.optString("version", "");
if (releaseFileName.isEmpty() || releaseVersion.isEmpty()) {
return false;
}
return releaseFileName.equals(downloadedFileName) && releaseVersion.equals(downloadedVersion);
}
@Nullable
private DownloadProgressSnapshot queryDownloadProgress(long downloadId) {
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager == null) {
return null;
}
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
try (android.database.Cursor cursor = manager.query(query)) {
if (cursor == null || !cursor.moveToFirst()) {
return null;
}
return new DownloadProgressSnapshot(
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)),
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)),
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
);
}
}
private static final class DownloadProgressSnapshot {
private final int status;
private final long bytesDownloaded;
private final long totalBytes;
private DownloadProgressSnapshot(int status, long bytesDownloaded, long totalBytes) {
this.status = status;
this.bytesDownloaded = bytesDownloaded;
this.totalBytes = totalBytes;
}
}
}

View File

@@ -1,7 +1,6 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.LinearLayout;
@@ -20,14 +19,12 @@ public class AiAccountsActivity extends BossScreenActivity {
private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api"};
private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API"};
private LinearLayout accountList;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("AI 账号", "主 GPT / 备用 GPT / API 容灾");
setHeaderAction("新增", v -> openAccountEditor(null, null));
replaceContent(buildIntroCard(), buildAccountListShell());
replaceContent();
reload();
}
@@ -48,56 +45,48 @@ public class AiAccountsActivity extends BossScreenActivity {
});
}
private LinearLayout buildIntroCard() {
return BossUi.buildCard(
this,
"账号说明",
"当前页面管理 Boss 的主控 AI 账号。主链路优先使用已绑定电脑上的 Master Codex NodeAPI 容灾在同页可补充配置。",
"支持新增、编辑、激活、校验和删除"
);
}
private LinearLayout buildAccountListShell() {
LinearLayout wrapper = new LinearLayout(this);
wrapper.setOrientation(LinearLayout.VERTICAL);
accountList = new LinearLayout(this);
accountList.setOrientation(LinearLayout.VERTICAL);
wrapper.addView(accountList);
return wrapper;
}
private void renderAccounts(JSONObject payload) {
JSONArray accounts = payload.optJSONArray("accounts");
JSONObject activeIdentity = payload.optJSONObject("activeIdentity");
JSONArray switchHistory = payload.optJSONArray("switchHistory");
accountList.removeAllViews();
replaceContent(buildIntroCard(), buildActiveIdentityCard(activeIdentity), buildAccountsSection(accounts), buildSwitchHistoryCard(switchHistory));
replaceContent();
appendContent(BossUi.buildSoftPanel(
this,
"AI 账号",
"这里统一管理主 GPT、备用 GPT 与 API 容灾账号。",
"轻点条目可编辑,按钮可切换、校验或删除。"
));
appendContent(buildActiveIdentityCard(activeIdentity));
appendContent(buildAccountsSection(accounts));
setRefreshing(false);
}
private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) {
String body = activeIdentity == null
? "当前没有可用的主控身份。"
: activeIdentity.optString("label", "AI 账号")
+ "\n" + activeIdentity.optString("displayName", "-")
+ "\n" + activeIdentity.optString("providerLabel", "-")
+ (activeIdentity.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + activeIdentity.optString("nodeLabel"));
String meta = activeIdentity == null
? "请先配置一个可用账号"
: activeIdentity.optString("roleLabel", "-") + " · " + activeIdentity.optString("statusLabel", "-");
return BossUi.buildCard(this, "当前主控身份", body, meta);
if (activeIdentity == null) {
return BossUi.buildSoftPanel(this, "当前主控身份", "当前没有可用账号。", "请先新增或启用一个账号。");
}
String body = activeIdentity.optString("label", "AI 账号")
+ " · " + activeIdentity.optString("displayName", "-")
+ "\n" + activeIdentity.optString("roleLabel", "-")
+ " · " + activeIdentity.optString("providerLabel", "-");
return BossUi.buildSoftPanel(
this,
"当前主控身份",
body,
activeIdentity.optString("statusLabel", "-")
);
}
private LinearLayout buildAccountsSection(@Nullable JSONArray accounts) {
LinearLayout section = new LinearLayout(this);
section.setOrientation(LinearLayout.VERTICAL);
section.addView(BossUi.buildCard(
section.addView(BossUi.buildWechatMenuRow(
this,
"账号列表",
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "击卡片可编辑,按钮可激活 / 校验 / 删除。",
"当前 API/api/v1/accounts"
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "可编辑,按钮可激活、校验或删除。",
null,
null,
null
));
if (accounts == null || accounts.length() == 0) {
@@ -118,66 +107,38 @@ public class AiAccountsActivity extends BossScreenActivity {
String meta = account.optString("roleLabel", "-")
+ " · " + account.optString("providerLabel", "-")
+ " · " + statusLabel
+ (account.optBoolean("isActive") ? " · 当前主控" : "")
+ (account.optBoolean("apiKeyConfigured") ? " · 已配置 Key" : "");
String body = account.optString("displayName", "-")
+ "\n账号" + account.optString("accountIdentifier", "-")
+ (account.optString("nodeLabel").isEmpty() ? "" : "\n节点" + account.optString("nodeLabel"))
+ (account.optString("loginStatusNote").isEmpty() ? "" : "\n" + account.optString("loginStatusNote"));
StringBuilder subtitle = new StringBuilder(account.optString("displayName", "-"));
if (!account.optString("accountIdentifier").isEmpty()) {
subtitle.append(" · ").append(account.optString("accountIdentifier", "-"));
}
if (!account.optString("nodeLabel").isEmpty()) {
subtitle.append(" · ").append(account.optString("nodeLabel", "-"));
}
LinearLayout card = BossUi.buildCard(
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
account.optString("label", "未命名账号"),
body,
subtitle.toString(),
meta,
account.optBoolean("isActive") ? "当前" : null,
v -> openAccountEditor(account, null)
);
Button activate = BossUi.buildPrimaryButton(this, account.optBoolean("isActive") ? "已激活" : "设为当前主控");
activate.setEnabled(!account.optBoolean("isActive"));
activate.setOnClickListener(v -> activateAccount(account));
card.addView(activate);
Button validate = BossUi.buildSecondaryButton(this, "校验连接");
validate.setOnClickListener(v -> validateAccount(account));
card.addView(validate);
Button edit = BossUi.buildSecondaryButton(this, "编辑账号");
edit.setOnClickListener(v -> openAccountEditor(account, null));
card.addView(edit);
Button delete = BossUi.buildSecondaryButton(this, "删除账号");
delete.setOnClickListener(v -> confirmDeleteAccount(account));
card.addView(delete);
return card;
}
private LinearLayout buildSwitchHistoryCard(@Nullable JSONArray switchHistory) {
LinearLayout section = new LinearLayout(this);
section.setOrientation(LinearLayout.VERTICAL);
section.addView(BossUi.buildCard(
this,
"切换历史",
switchHistory == null || switchHistory.length() == 0 ? "当前没有切换记录。" : "最近切换记录会保留 40 条。",
"用于追踪主控身份变化"
));
if (switchHistory == null || switchHistory.length() == 0) {
section.addView(BossUi.buildEmptyCard(this, "当前没有 AI 账号切换历史。"));
return section;
}
Button activate = BossUi.buildMiniActionButton(this, account.optBoolean("isActive") ? "当前主控" : "设为当前", !account.optBoolean("isActive"));
activate.setEnabled(!account.optBoolean("isActive"));
activate.setOnClickListener(v -> activateAccount(account));
for (int i = 0; i < switchHistory.length(); i++) {
JSONObject record = switchHistory.optJSONObject(i);
if (record == null) continue;
String body = "" + record.optString("fromLabel", "")
+ "\n到 " + record.optString("toLabel", "-")
+ "\n原因" + record.optString("reason", "-");
String meta = record.optString("role", "-") + " · " + record.optString("switchedAt", "-");
section.addView(BossUi.buildCard(this, "切换记录", body, meta));
}
return section;
Button validate = BossUi.buildMiniActionButton(this, "校验连接", false);
validate.setOnClickListener(v -> validateAccount(account));
Button delete = BossUi.buildMiniActionButton(this, "删除账号", false);
delete.setOnClickListener(v -> confirmDeleteAccount(account));
card.addView(BossUi.buildInlineActionRow(this, activate, validate, delete));
return card;
}
private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) {
@@ -217,18 +178,18 @@ public class AiAccountsActivity extends BossScreenActivity {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
form.addView(labelInput);
form.addView(displayNameInput);
form.addView(accountIdentifierInput);
form.addView(nodeIdInput);
form.addView(nodeLabelInput);
form.addView(modelInput);
form.addView(apiKeyInput);
form.addView(loginStatusInput);
form.addView(roleSpinner);
form.addView(providerSpinner);
form.addView(enabledSwitch);
form.addView(setActiveSwitch);
form.addView(BossUi.buildFormCell(this, "标签", "例如 主 GPT", labelInput));
form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput));
form.addView(BossUi.buildFormCell(this, "账号标识", "邮箱、登录名或备注信息", accountIdentifierInput));
form.addView(BossUi.buildFormCell(this, "节点 ID", "Master Codex Node 的唯一标识", nodeIdInput));
form.addView(BossUi.buildFormCell(this, "节点名称", "用于快速识别节点", nodeLabelInput));
form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
form.addView(BossUi.buildFormCell(this, "API Key", "仅 OpenAI API 模式需要", apiKeyInput));
form.addView(BossUi.buildFormCell(this, "登录状态备注", "可记录 Plus、有无风控等状态", loginStatusInput));
form.addView(BossUi.buildFormCell(this, "账号角色", null, roleSpinner));
form.addView(BossUi.buildFormCell(this, "提供方", null, providerSpinner));
form.addView(BossUi.buildFormCell(this, "启用状态", null, enabledSwitch));
form.addView(BossUi.buildFormCell(this, "保存后动作", null, setActiveSwitch));
new AlertDialog.Builder(this)
.setTitle(existing == null ? "新增 AI 账号" : "编辑 AI 账号")

View File

@@ -4,6 +4,8 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
@@ -31,8 +33,12 @@ public class BossApiClient {
private final String baseUrl;
public BossApiClient(Context context) {
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
this.baseUrl = BuildConfig.BOSS_API_BASE_URL;
this(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), BuildConfig.BOSS_API_BASE_URL);
}
BossApiClient(SharedPreferences prefs, String baseUrl) {
this.prefs = prefs;
this.baseUrl = baseUrl;
}
public boolean hasSessionHints() {
@@ -72,6 +78,21 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
}
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("name", name);
payload.put("mode", group ? "group" : "thread");
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/rename", payload);
}
public ApiResponse createGroupChat(String projectId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/group-chat", payload == null ? new JSONObject() : payload);
}
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
}
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("body", body);
@@ -79,11 +100,9 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
}
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("targetProjectId", targetProjectId);
payload.put("note", note);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", payload);
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
}
public ApiResponse getThreadDetail(String threadId) throws IOException, JSONException {
@@ -230,18 +249,26 @@ public class BossApiClient {
}
private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException {
ApiResponse response = request(method, path, body, true);
return requestWithRestoreRaw(method, path, body == null ? null : body.toString());
}
private ApiResponse requestWithRestoreRaw(String method, String path, @Nullable String body) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true);
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
ApiResponse restored = restoreSession();
if (restored.ok()) {
return request(method, path, body, true);
return requestRaw(method, path, body, true);
}
}
return response;
}
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection();
return requestRaw(method, path, body == null ? null : body.toString(), expectProtected);
}
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
HttpURLConnection connection = openConnection(path);
connection.setRequestMethod(method);
connection.setConnectTimeout(12000);
connection.setReadTimeout(12000);
@@ -260,7 +287,7 @@ public class BossApiClient {
connection.setRequestProperty("Content-Type", "application/json");
try (OutputStream outputStream = connection.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
writer.write(body.toString());
writer.write(body);
}
}
@@ -278,6 +305,10 @@ public class BossApiClient {
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
}
HttpURLConnection openConnection(String path) throws IOException {
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
}
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
if (stream == null) {
return new JSONObject();
@@ -316,7 +347,7 @@ public class BossApiClient {
}
}
private void rememberIdentity(JSONObject json) {
void rememberIdentity(JSONObject json) {
if (json == null) return;
JSONObject session = json.optJSONObject("session");
JSONObject source = session != null ? session : json;
@@ -347,7 +378,7 @@ public class BossApiClient {
.apply();
}
private String encode(String value) {
String encode(String value) {
return Uri.encode(value);
}

View File

@@ -28,7 +28,7 @@ public abstract class BossScreenActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_screen);
setContentView(getLayoutResId());
apiClient = new BossApiClient(this);
backButton = findViewById(R.id.screen_back_button);
@@ -44,6 +44,10 @@ public abstract class BossScreenActivity extends AppCompatActivity {
refreshLayout.setOnRefreshListener(this::reload);
}
protected int getLayoutResId() {
return R.layout.activity_screen;
}
@Override
protected void onDestroy() {
executor.shutdownNow();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,230 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
public class ConversationInfoActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private String projectId;
private String projectName;
private String projectFolderName;
private int participantCount;
@Override
protected int getLayoutResId() {
return R.layout.activity_conversation_info;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
configureScreen("会话信息", projectName == null ? "单线程会话信息页" : projectName);
setHeaderAction("重命名", v -> openRenameDialog());
reload();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
finish();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "会话信息加载失败:" + error.getMessage()));
});
}
});
}
private void renderConversation(JSONObject detail, JSONObject participantsPayload) {
replaceContent();
JSONObject project = detail.optJSONObject("project");
JSONArray participants = participantsPayload.optJSONArray("participants");
if (project == null) {
appendContent(BossUi.buildEmptyCard(this, "会话不存在。"));
setRefreshing(false);
return;
}
projectName = project.optString("name", projectName == null ? "会话信息" : projectName);
JSONObject threadMeta = project.optJSONObject("threadMeta");
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
participantCount = participants == null ? 0 : participants.length();
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
appendContent(BossUi.buildCard(
this,
projectName,
buildDetailBody(project, threadMeta),
buildDetailMeta(projectId, projectFolderName, participantCount)
));
appendContent(BossUi.buildMenuRow(
this,
"发起群聊",
"从当前会话选择其他线程,创建新的独立群聊",
null,
v -> openGroupCreate()
));
appendContent(BossUi.buildCard(
this,
"参与设备 / 线程",
"以下线程参与当前会话,点击可查看对应项目详情。",
participantCount == 0 ? "当前没有可展示的参与线程。" : "" + participantCount + " 个参与线程"
));
if (participants == null || participants.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前没有参与线程信息。"));
} else {
for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue;
appendContent(buildParticipantRow(participant));
}
}
setRefreshing(false);
}
private LinearLayout buildParticipantRow(JSONObject participant) {
boolean sourceProject = participant.optBoolean("isSourceProject", false);
String participantProjectId = participant.optString("projectId", "");
String title = participant.optString("threadDisplayName", "未命名线程");
String subtitle = participant.optString("folderName", "");
String meta = participant.optString("deviceId", "");
if (!participant.optString("threadId", "").isEmpty()) {
meta = meta.isEmpty() ? participant.optString("threadId", "") : meta + " · " + participant.optString("threadId", "");
}
return BossUi.buildListRow(
this,
title,
subtitle,
meta,
sourceProject ? "来源" : null,
v -> openProject(participantProjectId, title)
);
}
private void openProject(String targetProjectId, String targetProjectName) {
if (targetProjectId == null || targetProjectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName);
startActivity(intent);
}
private void openGroupCreate() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, GroupCreateActivity.class);
intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, projectName);
startActivity(intent);
}
private void openRenameDialog() {
final EditText input = BossUi.buildInput(this, "线程名", false);
input.setText(projectName == null ? "" : projectName);
new AlertDialog.Builder(this)
.setTitle("重命名会话")
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> saveConversationName(input.getText().toString().trim()))
.show();
}
private void saveConversationName(String name) {
if (name.isEmpty()) {
showMessage("线程名不能为空");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, false);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
Intent result = new Intent();
result.putExtra(EXTRA_PROJECT_NAME, name);
setResult(RESULT_OK, result);
showMessage("线程名已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private String buildSubtitle(@Nullable JSONObject threadMeta, int count) {
String folder = threadMeta == null ? "" : threadMeta.optString("folderName", "");
String suffix = count <= 0 ? "暂无参与线程" : count + " 个参与线程";
if (folder.isEmpty()) {
return suffix;
}
return folder + " · " + suffix;
}
private String buildDetailBody(JSONObject project, @Nullable JSONObject threadMeta) {
String threadId = threadMeta == null ? project.optString("id", "") : threadMeta.optString("threadId", "");
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
String deviceCount = project.optJSONArray("deviceIds") == null ? "0" : String.valueOf(project.optJSONArray("deviceIds").length());
StringBuilder builder = new StringBuilder();
builder.append("线程 ID").append(threadId.isEmpty() ? project.optString("id", "-") : threadId);
builder.append("\n文件夹").append(folderName.isEmpty() ? "未命名文件夹" : folderName);
builder.append("\n绑定设备").append(deviceCount);
builder.append("\n群聊状态").append(project.optBoolean("isGroup", false) ? "群聊" : "单线程");
return builder.toString();
}
private String buildDetailMeta(String projectId, String folderName, int count) {
StringBuilder builder = new StringBuilder();
if (!projectId.isEmpty()) {
builder.append("project ").append(projectId);
}
if (!folderName.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(folderName);
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(count <= 0 ? "暂无参与线程" : "参与线程 " + count);
return builder.toString();
}
}

View File

@@ -2,7 +2,6 @@ package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
@@ -23,7 +22,7 @@ public class DeviceDetailActivity extends BossScreenActivity {
super.onCreate(savedInstanceState);
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
configureScreen(deviceName == null ? "设备详情" : deviceName, "原生设备详情");
configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态与绑定项目");
setHeaderAction("编辑", v -> openEditDialog());
reload();
}
@@ -48,8 +47,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
private void renderDevice(JSONObject payload) {
JSONObject workspace = payload.optJSONObject("workspace");
JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice");
JSONArray relatedThreads = workspace == null ? null : workspace.optJSONArray("relatedThreads");
JSONObject enrollment = workspace == null ? null : workspace.optJSONObject("activeEnrollment");
replaceContent();
if (device == null) {
@@ -59,56 +56,28 @@ public class DeviceDetailActivity extends BossScreenActivity {
}
deviceName = device.optString("name", deviceId);
configureScreen(deviceName, device.optString("endpoint", "设备详情"));
appendContent(BossUi.buildCard(
configureScreen(deviceName, "设备状态与绑定项目");
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device);
appendContent(BossUi.buildDeviceCard(
this,
device.optString("name", "设备"),
device.optString("note", "暂无备注"),
"状态 " + device.optString("status", "unknown")
+ " · 账号 " + device.optString("account", "-")
+ " · 5h " + device.optInt("quota5h", 0)
+ " · 7d " + device.optInt("quota7d", 0)
WechatSurfaceMapper.toDeviceRow(device),
null,
null
));
Button skillsButton = BossUi.buildPrimaryButton(this, "查看技能");
skillsButton.setOnClickListener(v -> openSkills());
appendContent(skillsButton);
if (relatedThreads != null && relatedThreads.length() > 0) {
for (int i = 0; i < relatedThreads.length(); i++) {
JSONObject thread = relatedThreads.optJSONObject(i);
if (thread == null) continue;
appendContent(BossUi.buildCard(
this,
thread.optString("title", "线程"),
thread.optString("summary", ""),
thread.optString("workerId", "-")
+ " · " + thread.optInt("contextBudgetRemainingPct", 0) + "%"
+ " · " + thread.optString("contextBudgetLevel", "safe"),
v -> openThread(thread.optString("threadId"))
));
}
}
if (enrollment != null) {
appendContent(BossUi.buildCard(
if (summary.meta != null && !summary.meta.isEmpty()) {
appendContent(BossUi.buildWechatMenuRow(
this,
"当前绑定草稿",
"pairingCode " + enrollment.optString("pairingCode", "-")
+ "\ntoken " + enrollment.optString("token", "-"),
enrollment.optString("status", "ready")
+ " · 到期 " + enrollment.optString("expiresAt", "-")
"设备说明",
summary.meta,
null,
null,
null
));
}
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
setRefreshing(false);
}
private void openThread(String threadId) {
Intent intent = new Intent(this, ThreadDetailActivity.class);
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
startActivity(intent);
}
private void openSkills() {
Intent intent = new Intent(this, SkillInventoryActivity.class);
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, deviceId);

View File

@@ -19,7 +19,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("添加设备", "通过 pairing code 或 token 把新设备接入");
configureScreen("添加设备", "填写设备信息后生成配对草稿");
hideHeaderAction();
buildForm();
}
@@ -38,23 +38,24 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
noteInput = BossUi.buildInput(this, "备注", true);
projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true);
android.widget.Button submitButton = BossUi.buildPrimaryButton(this, "生成绑定草稿");
submitButton.setOnClickListener(v -> submitEnrollment());
replaceContent(
BossUi.buildCard(
BossUi.buildSoftPanel(
this,
"绑定新设备",
"支持通过 pairing code、临时 token 或登录引导把 Mac、Windows、云端节点接入",
"当前原生页会直接调用 /api/v1/devices/enrollments"
"接入新设备",
"支持通过 pairing code token 接入 Mac、Windows、云端节点。",
"生成后把配对码交给设备端即可完成绑定。"
),
nameInput,
avatarInput,
accountInput,
endpointInput,
noteInput,
projectsInput,
BossUi.buildPrimaryButton(this, "生成绑定草稿")
BossUi.buildFormCell(this, "设备名称", "例如 Mac Studio 或 Windows GPU", nameInput),
BossUi.buildFormCell(this, "头像字符", "会显示在设备卡片左侧", avatarInput),
BossUi.buildFormCell(this, "所属账号", "默认使用当前登录账号", accountInput),
BossUi.buildFormCell(this, "设备地址", "例如 mac://kris.local", endpointInput),
BossUi.buildFormCell(this, "设备备注", "可填写位置、用途或节点说明", noteInput),
BossUi.buildFormCell(this, "项目列表", "多个项目用逗号分隔", projectsInput),
submitButton
);
((android.widget.Button) contentLayout.getChildAt(contentLayout.getChildCount() - 1))
.setOnClickListener(v -> submitEnrollment());
}
private void submitEnrollment() {
@@ -80,7 +81,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
JSONObject enrollment = response.json.optJSONObject("enrollment");
JSONObject device = response.json.optJSONObject("device");
replaceContent(
BossUi.buildCard(
BossUi.buildSoftPanel(
this,
"绑定草稿已生成",
"设备 " + (device == null ? "-" : device.optString("name", "-"))

View File

@@ -0,0 +1,248 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class ForwardPayloads {
private ForwardPayloads() {}
public static JSONObject build(
String mode,
@Nullable String sourceMessageId,
@Nullable List<String> sourceMessageIds
) throws JSONException {
MutableJsonObject payload = new MutableJsonObject();
String normalizedMode = isEmpty(mode) ? "single" : mode;
payload.put("mode", normalizedMode);
if (normalizedMode.startsWith("single")) {
String resolvedSourceMessageId = sourceMessageId;
if (isEmpty(resolvedSourceMessageId) && sourceMessageIds != null && sourceMessageIds.size() == 1) {
resolvedSourceMessageId = sourceMessageIds.get(0);
}
if (isEmpty(resolvedSourceMessageId)) {
throw new JSONException("sourceMessageId required");
}
payload.put("sourceMessageId", resolvedSourceMessageId);
return payload;
}
MutableJsonArray orderedIds = new MutableJsonArray();
if (sourceMessageIds != null) {
for (String messageId : sourceMessageIds) {
if (!isEmpty(messageId)) {
orderedIds.put(messageId);
}
}
}
if (orderedIds.length() == 0) {
throw new JSONException("sourceMessageIds required");
}
payload.put("sourceMessageIds", orderedIds);
return payload;
}
public static String toRequestBody(String targetProjectId, @Nullable JSONObject payload) throws JSONException {
MutableJsonObject requestPayload = new MutableJsonObject();
requestPayload.put("targetProjectId", targetProjectId);
if (payload == null) {
return requestPayload.toString();
}
String mode = payload.optString("mode", "");
if (!isEmpty(mode)) {
requestPayload.put("mode", mode);
}
String sourceMessageId = payload.optString("sourceMessageId", "");
if (!isEmpty(sourceMessageId)) {
requestPayload.put("sourceMessageId", sourceMessageId);
}
JSONArray sourceMessageIds = payload.optJSONArray("sourceMessageIds");
if (sourceMessageIds != null && sourceMessageIds.length() > 0) {
MutableJsonArray orderedIds = new MutableJsonArray();
for (int i = 0; i < sourceMessageIds.length(); i++) {
String messageId = sourceMessageIds.optString(i);
if (!isEmpty(messageId)) {
orderedIds.put(messageId);
}
}
if (orderedIds.length() > 0) {
requestPayload.put("sourceMessageIds", orderedIds);
}
}
return requestPayload.toString();
}
public static boolean isApprovalRequired(@Nullable JSONObject responseJson) {
return responseJson != null && responseJson.optBoolean("approvalRequired", false);
}
private static boolean isEmpty(@Nullable String value) {
return value == null || value.length() == 0;
}
private static final class MutableJsonObject extends JSONObject {
private final Map<String, Object> values = new LinkedHashMap<>();
@Override
public JSONObject put(String key, boolean value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, int value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, long value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, Object value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
String value = optString(key);
return value.isEmpty() ? fallback : value;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
@Override
public boolean optBoolean(String key, boolean fallback) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : fallback;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> entry : values.entrySet()) {
if (!first) {
builder.append(",");
}
first = false;
builder.append("\"").append(escape(entry.getKey())).append("\":");
builder.append(stringify(entry.getValue()));
}
builder.append("}");
return builder.toString();
}
}
private static final class MutableJsonArray extends JSONArray {
private final ArrayList<Object> values = new ArrayList<>();
@Override
public JSONArray put(boolean value) {
values.add(value);
return this;
}
@Override
public JSONArray put(int value) {
values.add(value);
return this;
}
@Override
public JSONArray put(long value) {
values.add(value);
return this;
}
@Override
public JSONArray put(Object value) {
values.add(value);
return this;
}
@Override
public int length() {
return values.size();
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= values.size()) {
return null;
}
Object value = values.get(index);
return value instanceof JSONObject ? (JSONObject) value : null;
}
@Override
public String optString(int index) {
if (index < 0 || index >= values.size()) {
return "";
}
Object value = values.get(index);
return value instanceof String ? (String) value : "";
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder("[");
for (int i = 0; i < values.size(); i++) {
if (i > 0) {
builder.append(",");
}
builder.append(stringify(values.get(i)));
}
builder.append("]");
return builder.toString();
}
}
private static String stringify(@Nullable Object value) {
if (value == null) {
return "null";
}
if (value instanceof String) {
return "\"" + escape((String) value) + "\"";
}
if (value instanceof Number || value instanceof Boolean) {
return String.valueOf(value);
}
return value.toString();
}
private static String escape(String value) {
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}

View File

@@ -0,0 +1,195 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public class ForwardTargetActivity extends BossScreenActivity {
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
public static final String EXTRA_FORWARD_MODE = "forward_mode";
public static final String EXTRA_SOURCE_MESSAGE_ID = "source_message_id";
public static final String EXTRA_SOURCE_MESSAGE_IDS = "source_message_ids";
private String sourceProjectId;
private String forwardMode;
@Nullable
private String sourceMessageId;
private final ArrayList<String> sourceMessageIds = new ArrayList<>();
@Override
protected int getLayoutResId() {
return R.layout.activity_forward_target;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
sourceProjectId = intent.getStringExtra(EXTRA_SOURCE_PROJECT_ID);
forwardMode = intent.getStringExtra(EXTRA_FORWARD_MODE);
sourceMessageId = intent.getStringExtra(EXTRA_SOURCE_MESSAGE_ID);
String[] messageIds = intent.getStringArrayExtra(EXTRA_SOURCE_MESSAGE_IDS);
if (messageIds != null) {
for (String messageId : messageIds) {
if (!TextUtils.isEmpty(messageId)) {
sourceMessageIds.add(messageId);
}
}
}
configureScreen("选择转发目标", buildSourceMeta());
reload();
}
@Override
protected void reload() {
if (isEmpty(sourceProjectId)) {
showMessage("缺少源会话");
finish();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getConversations();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONArray conversations = response.json.optJSONArray("conversations");
List<JSONObject> targets = collectSelectableTargets(conversations, sourceProjectId);
runOnUiThread(() -> renderTargets(targets));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
});
}
});
}
public static List<JSONObject> collectSelectableTargets(JSONArray conversations, String sourceProjectId) {
ArrayList<JSONObject> result = new ArrayList<>();
if (conversations == null) {
return result;
}
for (int i = 0; i < conversations.length(); i++) {
JSONObject item = conversations.optJSONObject(i);
if (item == null) {
continue;
}
if (!isEmpty(sourceProjectId) && sourceProjectId.equals(item.optString("projectId", ""))) {
continue;
}
result.add(item);
}
return result;
}
public static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, List<String> sourceMessageIds)
throws JSONException {
return ForwardPayloads.build(mode, sourceMessageId, sourceMessageIds);
}
static String resolveForwardResultMessage(JSONObject responseJson) {
return ForwardPayloads.isApprovalRequired(responseJson) ? "已提交主 Agent 审批" : "转发成功";
}
private void renderTargets(List<JSONObject> targets) {
replaceContent(
BossUi.buildCard(
this,
"正在选择转发目标",
buildSourceBody(),
buildSourceMeta()
)
);
if (targets.isEmpty()) {
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标会话。"));
setRefreshing(false);
return;
}
for (JSONObject target : targets) {
appendContent(BossUi.buildConversationRow(
this,
WechatSurfaceMapper.toConversationRow(target),
v -> forwardToTarget(target)
));
}
setRefreshing(false);
}
private String buildSourceBody() {
StringBuilder builder = new StringBuilder();
builder.append("源会话:").append(isEmpty(sourceProjectId) ? "-" : sourceProjectId);
builder.append("\n转发模式").append(isEmpty(forwardMode) ? "single" : forwardMode);
return builder.toString();
}
private String buildSourceMeta() {
int messageCount = sourceMessageIds.size();
if (!isEmpty(sourceMessageId)) {
return "source_message_id 已就绪";
}
if (messageCount > 0) {
return "source_message_ids " + messageCount + "";
}
return "等待聊天页入口补充消息选择";
}
private void forwardToTarget(JSONObject target) {
if (target == null) {
showMessage("目标会话无效");
return;
}
String targetProjectId = target.optString("projectId", "");
if (isEmpty(targetProjectId)) {
showMessage("目标会话无效");
return;
}
try {
JSONObject payload = buildForwardPayload(
forwardMode,
sourceMessageId,
sourceMessageIds
);
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(sourceProjectId, targetProjectId, payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
setRefreshing(false);
showMessage(resolveForwardResultMessage(response.json));
setResult(RESULT_OK);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("转发失败:" + error.getMessage());
});
}
});
} catch (JSONException error) {
showMessage("缺少源消息,暂无法转发");
}
}
private static boolean isEmpty(@Nullable String value) {
return value == null || value.length() == 0;
}
}

View File

@@ -0,0 +1,356 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class GroupCreateActivity extends BossScreenActivity {
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
public static final String EXTRA_SOURCE_PROJECT_NAME = "source_project_name";
private final List<CandidateConversation> candidates = new ArrayList<>();
private final Set<String> selectedProjectIds = new LinkedHashSet<>();
private final Set<String> lastCandidateProjectIds = new LinkedHashSet<>();
private String sourceProjectId;
private String sourceProjectName;
private String sourceFolderName;
private LinearLayout candidateListLayout;
private Button createButton;
private boolean creatingGroupChat;
private JSONObject cachedParticipantsPayload;
private JSONObject cachedConversationsPayload;
@Override
protected int getLayoutResId() {
return R.layout.activity_group_create;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
sourceProjectId = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_ID);
sourceProjectName = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_NAME);
configureScreen("发起群聊", sourceProjectName == null ? "从当前会话出发" : sourceProjectName);
reload();
}
@Override
protected void reload() {
if (sourceProjectId == null || sourceProjectId.isEmpty()) {
showMessage("缺少 projectId");
finish();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId);
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations();
if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message());
runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "群聊创建页加载失败:" + error.getMessage()));
});
}
});
}
private void renderCreatePage(JSONObject participantsPayload, JSONObject conversationsPayload, boolean rebuildCandidates) {
cachedParticipantsPayload = participantsPayload;
cachedConversationsPayload = conversationsPayload;
replaceContent();
JSONObject threadMeta = participantsPayload.optJSONObject("threadMeta");
JSONArray participants = participantsPayload.optJSONArray("participants");
sourceFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
sourceProjectName = threadMeta == null
? sourceProjectName
: threadMeta.optString("threadDisplayName", sourceProjectName == null ? "当前会话" : sourceProjectName);
appendContent(BossUi.buildCard(
this,
"新建独立群聊",
"群聊不是升级原会话,而是以当前会话为源,新建一个独立线程。",
buildSourceMeta(threadMeta, participants)
));
appendContent(BossUi.buildCard(
this,
sourceProjectName,
buildSourceBody(threadMeta, participants),
sourceProjectId + (sourceFolderName.isEmpty() ? "" : " · " + sourceFolderName)
));
if (rebuildCandidates) {
List<JSONObject> selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId);
List<CandidateConversation> nextCandidates = new ArrayList<>(selectableConversations.size());
Set<String> nextCandidateProjectIds = new LinkedHashSet<>();
for (JSONObject item : selectableConversations) {
CandidateConversation candidate = new CandidateConversation(
item.optString("projectId", ""),
item.optString("projectTitle", item.optString("threadTitle", "未命名会话")),
item.optString("folderLabel", ""),
item.optString("lastMessagePreview", item.optString("preview", "")),
item.optString("latestReplyLabel", ""),
false
);
nextCandidates.add(candidate);
nextCandidateProjectIds.add(candidate.projectId);
}
Set<String> currentSelectedProjectIds = new LinkedHashSet<>(selectedProjectIds);
candidates.clear();
candidates.addAll(nextCandidates);
selectedProjectIds.clear();
selectedProjectIds.addAll(reconcileSelectedProjectIds(
currentSelectedProjectIds,
lastCandidateProjectIds,
nextCandidateProjectIds
));
lastCandidateProjectIds.clear();
lastCandidateProjectIds.addAll(nextCandidateProjectIds);
}
appendContent(BossUi.buildCard(
this,
"选择其他线程",
candidates.isEmpty()
? "当前没有可加入的其他线程。"
: selectedProjectIds.isEmpty()
? "你已取消全部勾选,可继续手动选择。"
: "已保留你当前的勾选状态。",
"已选 " + selectedProjectIds.size() + " 个线程"
));
candidateListLayout = new LinearLayout(this);
candidateListLayout.setOrientation(LinearLayout.VERTICAL);
for (CandidateConversation candidate : candidates) {
candidateListLayout.addView(buildCandidateRow(candidate));
}
if (candidates.isEmpty()) {
candidateListLayout.addView(BossUi.buildEmptyCard(this, "当前没有可选择的其他线程。"));
}
appendContent(candidateListLayout);
createButton = BossUi.buildPrimaryButton(this, "创建群聊");
createButton.setOnClickListener(v -> createGroupChat());
appendContent(createButton);
Button cancelButton = BossUi.buildSecondaryButton(this, "取消");
cancelButton.setOnClickListener(v -> finish());
appendContent(cancelButton);
setRefreshing(false);
updateCreateButtonState();
}
static List<JSONObject> collectSelectableConversationItems(@Nullable JSONObject conversationsPayload, String sourceProjectId) {
List<JSONObject> result = new ArrayList<>();
JSONArray conversations = conversationsPayload == null ? null : conversationsPayload.optJSONArray("conversations");
if (conversations == null) {
return result;
}
for (int i = 0; i < conversations.length(); i++) {
JSONObject item = conversations.optJSONObject(i);
if (item == null) continue;
String projectId = item.optString("projectId", "");
if (projectId.isEmpty() || sourceProjectId.equals(projectId) || item.optBoolean("isGroup", false)) {
continue;
}
result.add(item);
}
return result;
}
private LinearLayout buildCandidateRow(CandidateConversation candidate) {
boolean selected = selectedProjectIds.contains(candidate.projectId);
String badge = selected ? "已选" : "未选";
String subtitle = candidate.folderLabel.isEmpty() ? candidate.latestReplyLabel : candidate.folderLabel;
String meta = candidate.preview;
if (!candidate.latestReplyLabel.isEmpty() && !candidate.latestReplyLabel.equals(candidate.preview)) {
meta = candidate.latestReplyLabel + (meta.isEmpty() ? "" : " · " + meta);
}
return BossUi.buildListRow(
this,
candidate.title,
subtitle,
meta,
badge,
v -> toggleSelection(candidate.projectId)
);
}
private void toggleSelection(String projectId) {
if (selectedProjectIds.contains(projectId)) {
selectedProjectIds.remove(projectId);
} else {
selectedProjectIds.add(projectId);
}
refreshCandidateRows();
updateCreateButtonState();
}
private void refreshCandidateRows() {
if (cachedParticipantsPayload == null || cachedConversationsPayload == null) {
return;
}
renderCreatePage(cachedParticipantsPayload, cachedConversationsPayload, false);
}
private void updateCreateButtonState() {
if (createButton != null) {
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds));
createButton.setText(creatingGroupChat ? "创建中..." : "创建群聊");
}
}
private void createGroupChat() {
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
if (refreshing || creatingGroupChat) {
return;
}
if (selectedProjectIds.isEmpty()) {
showMessage("请至少选择一个其他线程");
return;
}
List<String> memberProjectIdsSnapshot = new ArrayList<>(selectedProjectIds);
creatingGroupChat = true;
setRefreshing(true);
updateCreateButtonState();
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
JSONArray memberProjectIds = new JSONArray();
for (String projectId : memberProjectIdsSnapshot) {
memberProjectIds.put(projectId);
}
payload.put("memberProjectIds", memberProjectIds);
BossApiClient.ApiResponse response = apiClient.createGroupChat(sourceProjectId, payload);
if (!response.ok()) throw new IllegalStateException(response.message());
JSONObject project = response.json.optJSONObject("project");
if (project == null) throw new IllegalStateException("GROUP_CHAT_PROJECT_MISSING");
String createdProjectId = project.optString("id", "");
if (createdProjectId.isEmpty()) {
throw new IllegalStateException("GROUP_CHAT_PROJECT_ID_MISSING");
}
String createdProjectName = project.optString("name", sourceProjectName == null ? "群聊" : sourceProjectName);
runOnUiThread(() -> {
setRefreshing(false);
creatingGroupChat = false;
updateCreateButtonState();
showMessage("群聊已创建");
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, createdProjectId);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, createdProjectName);
startActivity(intent);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
creatingGroupChat = false;
setRefreshing(false);
showMessage("创建失败:" + error.getMessage());
updateCreateButtonState();
});
}
});
}
static boolean canCreateGroupChat(
boolean refreshing,
boolean creatingGroupChat,
@Nullable Set<String> selectedProjectIds
) {
return !refreshing
&& !creatingGroupChat
&& selectedProjectIds != null
&& !selectedProjectIds.isEmpty();
}
static Set<String> reconcileSelectedProjectIds(
@Nullable Set<String> currentSelectedProjectIds,
@Nullable Set<String> previousCandidateProjectIds,
@Nullable Set<String> nextCandidateProjectIds
) {
Set<String> reconciled = new LinkedHashSet<>();
if (nextCandidateProjectIds == null || nextCandidateProjectIds.isEmpty()) {
return reconciled;
}
if (previousCandidateProjectIds == null
|| previousCandidateProjectIds.isEmpty()
|| !previousCandidateProjectIds.equals(nextCandidateProjectIds)) {
reconciled.addAll(nextCandidateProjectIds);
return reconciled;
}
if (currentSelectedProjectIds == null || currentSelectedProjectIds.isEmpty()) {
return reconciled;
}
for (String projectId : currentSelectedProjectIds) {
if (nextCandidateProjectIds.contains(projectId)) {
reconciled.add(projectId);
}
}
return reconciled;
}
private String buildSourceMeta(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) {
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
int count = participants == null ? 0 : participants.length();
String memberLabel = count <= 0 ? "暂无参与线程" : count + " 个参与线程";
if (folderName.isEmpty()) {
return memberLabel;
}
return folderName + " · " + memberLabel;
}
private String buildSourceBody(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) {
String threadId = threadMeta == null ? sourceProjectId : threadMeta.optString("threadId", sourceProjectId);
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
StringBuilder builder = new StringBuilder();
builder.append("来源线程:").append(threadId);
builder.append("\n文件夹").append(folderName.isEmpty() ? "未命名文件夹" : folderName);
builder.append("\n参与线程").append(participants == null ? 0 : participants.length());
builder.append("\n默认规则会自动勾选当前会话之外的其他线程");
return builder.toString();
}
private static final class CandidateConversation {
private final String projectId;
private final String title;
private final String folderLabel;
private final String preview;
private final String latestReplyLabel;
private final boolean isGroup;
private CandidateConversation(
String projectId,
String title,
String folderLabel,
String preview,
String latestReplyLabel,
boolean isGroup
) {
this.projectId = projectId;
this.title = title;
this.folderLabel = folderLabel;
this.preview = preview;
this.latestReplyLabel = latestReplyLabel;
this.isGroup = isGroup;
}
}
}

View File

@@ -0,0 +1,208 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
public class GroupInfoActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private String projectId;
private String projectName;
@Override
protected int getLayoutResId() {
return R.layout.activity_group_info;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
configureScreen("群资料", projectName == null ? "群聊资料页" : projectName);
setHeaderAction("重命名", v -> openRenameDialog());
reload();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
finish();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "群资料加载失败:" + error.getMessage()));
});
}
});
}
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
replaceContent();
JSONObject project = detail.optJSONObject("project");
JSONArray participants = participantsPayload.optJSONArray("participants");
if (project == null) {
appendContent(BossUi.buildEmptyCard(this, "群聊不存在。"));
setRefreshing(false);
return;
}
projectName = project.optString("name", projectName == null ? "群聊" : projectName);
JSONObject threadMeta = project.optJSONObject("threadMeta");
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
int participantCount = participants == null ? 0 : participants.length();
configureScreen("群资料", buildSubtitle(folderName, participantCount));
appendContent(BossUi.buildCard(
this,
projectName,
buildDetailBody(project, threadMeta),
buildDetailMeta(projectId, folderName, participantCount)
));
appendContent(BossUi.buildCard(
this,
"成员线程",
"群聊成员可点击查看对应项目详情。",
participantCount == 0 ? "当前没有成员线程。" : "" + participantCount + " 个成员"
));
if (participants == null || participants.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前没有群成员信息。"));
} else {
for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue;
appendContent(buildMemberRow(participant));
}
}
setRefreshing(false);
}
private LinearLayout buildMemberRow(JSONObject participant) {
boolean sourceProject = participant.optBoolean("isSourceProject", false);
String participantProjectId = participant.optString("projectId", "");
String title = participant.optString("threadDisplayName", "未命名线程");
String subtitle = participant.optString("folderName", "");
String meta = participant.optString("deviceId", "");
String threadId = participant.optString("threadId", "");
if (!threadId.isEmpty()) {
meta = meta.isEmpty() ? threadId : meta + " · " + threadId;
}
return BossUi.buildListRow(
this,
title,
subtitle,
meta,
sourceProject ? "当前" : null,
v -> openProject(participantProjectId, title)
);
}
private void openProject(String targetProjectId, String targetProjectName) {
if (targetProjectId == null || targetProjectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName);
startActivity(intent);
}
private void openRenameDialog() {
final EditText input = BossUi.buildInput(this, "群名", false);
input.setText(projectName == null ? "" : projectName);
new AlertDialog.Builder(this)
.setTitle("重命名群聊")
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> saveGroupName(input.getText().toString().trim()))
.show();
}
private void saveGroupName(String name) {
if (name.isEmpty()) {
showMessage("群名不能为空");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, true);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
Intent result = new Intent();
result.putExtra(EXTRA_PROJECT_NAME, name);
setResult(RESULT_OK, result);
showMessage("群名已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private String buildSubtitle(String folderName, int count) {
String memberLabel = count <= 0 ? "暂无成员" : count + " 个成员";
if (folderName.isEmpty()) {
return memberLabel;
}
return folderName + " · " + memberLabel;
}
private String buildDetailBody(JSONObject project, @Nullable JSONObject threadMeta) {
String threadId = threadMeta == null ? project.optString("id", "") : threadMeta.optString("threadId", "");
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
StringBuilder builder = new StringBuilder();
builder.append("群聊线程:").append(threadId.isEmpty() ? project.optString("id", "-") : threadId);
builder.append("\n群聊名称").append(project.optString("name", "群聊"));
builder.append("\n文件夹").append(folderName.isEmpty() ? "未命名文件夹" : folderName);
builder.append("\n协作模式").append(project.optString("collaborationMode", "development"));
return builder.toString();
}
private String buildDetailMeta(String projectId, String folderName, int count) {
StringBuilder builder = new StringBuilder();
if (!projectId.isEmpty()) {
builder.append("project ").append(projectId);
}
if (!folderName.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(folderName);
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(count <= 0 ? "暂无成员" : "成员 " + count);
return builder.toString();
}
}

View File

@@ -22,6 +22,9 @@ import java.util.concurrent.Executors;
public class MainActivity extends AppCompatActivity {
public static final String EXTRA_INITIAL_TAB = "initial_tab";
private static final String UI_PREFS = "boss_native_client";
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
@@ -29,6 +32,7 @@ public class MainActivity extends AppCompatActivity {
private View loginPanel;
private View contentPanel;
private TextView loginTitle;
private TextView loginHint;
private Button loginButton;
private ProgressBar loginProgress;
@@ -45,12 +49,15 @@ public class MainActivity extends AppCompatActivity {
private String activeTab = "conversations";
private String preferredEntryTab = "conversations";
private boolean explicitTabRequest = false;
private @Nullable String requestedInitialTab;
private boolean userSelectedTab = false;
private long lastRootBackPressedAt = 0L;
private @Nullable JSONObject sessionData;
private @Nullable JSONObject otaData;
private @Nullable JSONArray conversationsData;
private @Nullable JSONArray devicesData;
private @Nullable String boundDeviceId;
private @Nullable String boundDeviceName;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -78,10 +85,17 @@ public class MainActivity extends AppCompatActivity {
public void onBackPressed() {
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
setActiveTab("conversations", false);
persistLastRootTab("conversations");
return;
}
if (contentPanel.getVisibility() == View.VISIBLE) {
moveTaskToBack(true);
long now = System.currentTimeMillis();
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
moveTaskToBack(true);
return;
}
lastRootBackPressedAt = now;
showMessage("再按一次返回,应用进入后台");
return;
}
super.onBackPressed();
@@ -96,6 +110,7 @@ public class MainActivity extends AppCompatActivity {
private void bindViews() {
loginPanel = findViewById(R.id.login_panel);
contentPanel = findViewById(R.id.content_panel);
loginTitle = findViewById(R.id.login_title);
loginHint = findViewById(R.id.login_hint);
loginButton = findViewById(R.id.login_button);
loginProgress = findViewById(R.id.login_progress);
@@ -108,12 +123,20 @@ public class MainActivity extends AppCompatActivity {
tabMe = findViewById(R.id.tab_me);
screenRefresh = findViewById(R.id.screen_refresh);
screenContent = findViewById(R.id.screen_content);
String[] rootTabs = WechatSurfaceMapper.rootTabLabels();
tabConversations.setText(rootTabs[0]);
tabDevices.setText(rootTabs[1]);
tabMe.setText(rootTabs[2]);
loginTitle.setText(WechatSurfaceMapper.loginTitle());
loginHint.setText(WechatSurfaceMapper.loginHintText());
loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
}
private void bindActions() {
loginButton.setOnClickListener(v -> performAutoLogin());
backButton.setVisibility(View.GONE);
refreshButton.setOnClickListener(v -> refreshCurrentTab());
refreshButton.setOnClickListener(v -> handleTopAction());
tabConversations.setOnClickListener(v -> setActiveTab("conversations", true));
tabDevices.setOnClickListener(v -> setActiveTab("devices", true));
tabMe.setOnClickListener(v -> setActiveTab("me", true));
@@ -121,16 +144,16 @@ public class MainActivity extends AppCompatActivity {
}
private void applyInitialTab(@Nullable Intent intent) {
explicitTabRequest = false;
requestedInitialTab = null;
String requested = intent == null ? null : intent.getStringExtra(EXTRA_INITIAL_TAB);
if ("devices".equals(requested) || "me".equals(requested) || "conversations".equals(requested)) {
activeTab = requested;
explicitTabRequest = true;
requestedInitialTab = requested;
}
activeTab = RootTabMemory.resolveInitialTab(requestedInitialTab, readLastRootTab(), preferredEntryTab);
}
private void bootstrapSession() {
showLogin("原生 Android 客户端已启用。点击下方按钮直接进入系统。");
showLogin(WechatSurfaceMapper.loginHintText());
if (!apiClient.hasSessionHints()) {
return;
}
@@ -153,7 +176,7 @@ public class MainActivity extends AppCompatActivity {
} catch (Exception ignored) {
// Fall back to login panel.
}
runOnUiThread(() -> setLoginLoading(false, "点击登录后会直接进入系统。"));
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
});
}
@@ -183,10 +206,12 @@ public class MainActivity extends AppCompatActivity {
private void refreshAllData(@Nullable JSONObject initialSession) {
startRefreshing(true);
topSubtitle.setText("正在同步最新数据...");
topSubtitle.setText("");
topSubtitle.setVisibility(View.GONE);
executor.execute(() -> {
JSONObject session;
try {
JSONObject session = initialSession;
session = initialSession;
if (session == null) {
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
if (!sessionResponse.ok()) {
@@ -197,29 +222,6 @@ public class MainActivity extends AppCompatActivity {
}
session = sessionResponse.json.optJSONObject("session");
}
BossApiClient.ApiResponse conversations = apiClient.getConversations();
BossApiClient.ApiResponse devices = apiClient.getDevices();
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
BossApiClient.ApiResponse settings = apiClient.getSettings();
if (!conversations.ok() || !devices.ok() || !ota.ok() || !settings.ok()) {
throw new IOException("API_REFRESH_FAILED");
}
JSONObject finalSession = session;
runOnUiThread(() -> {
sessionData = finalSession;
conversationsData = conversations.json.optJSONArray("conversations");
devicesData = devices.json.optJSONArray("devices");
otaData = ota.json;
JSONObject settingsPayload = settings.json.optJSONObject("settings");
if (settingsPayload != null) {
preferredEntryTab = settingsPayload.optString("preferredEntryPoint", "conversations");
}
maybeApplyPreferredEntry();
renderCurrentTab();
startRefreshing(false);
});
} catch (Exception error) {
runOnUiThread(() -> {
startRefreshing(false);
@@ -229,6 +231,94 @@ public class MainActivity extends AppCompatActivity {
otaData = null;
showLogin("当前登录已失效或同步失败,请重新点击登录。");
});
return;
}
try {
BossApiClient.ApiResponse conversations = null;
BossApiClient.ApiResponse devices = null;
BossApiClient.ApiResponse ota = null;
BossApiClient.ApiResponse settings = null;
boolean conversationsOk = false;
boolean devicesOk = false;
boolean otaOk = false;
boolean settingsOk = false;
try {
conversations = apiClient.getConversations();
conversationsOk = conversations.ok();
} catch (Exception ignored) {
conversationsOk = false;
}
try {
devices = apiClient.getDevices();
devicesOk = devices.ok();
} catch (Exception ignored) {
devicesOk = false;
}
try {
ota = apiClient.getOtaStatus();
otaOk = ota.ok();
} catch (Exception ignored) {
otaOk = false;
}
try {
settings = apiClient.getSettings();
settingsOk = settings.ok();
} catch (Exception ignored) {
settingsOk = false;
}
JSONObject finalSession = session;
BossApiClient.ApiResponse finalConversations = conversations;
BossApiClient.ApiResponse finalDevices = devices;
BossApiClient.ApiResponse finalOta = ota;
BossApiClient.ApiResponse finalSettings = settings;
final boolean finalConversationsOk = conversationsOk;
final boolean finalDevicesOk = devicesOk;
final boolean finalOtaOk = otaOk;
final boolean finalSettingsOk = settingsOk;
runOnUiThread(() -> {
sessionData = finalSession;
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
conversationsData,
finalConversations == null ? null : finalConversations.json.optJSONArray("conversations"),
finalConversationsOk
);
devicesData = WechatSurfaceMapper.resolveRefreshValue(
devicesData,
finalDevices == null ? null : finalDevices.json.optJSONArray("devices"),
finalDevicesOk
);
otaData = WechatSurfaceMapper.resolveRefreshValue(
otaData,
finalOta == null ? null : finalOta.json,
finalOtaOk
);
JSONObject settingsPayload = finalSettings == null ? null : finalSettings.json.optJSONObject("settings");
JSONObject userPayload = finalSettings == null ? null : finalSettings.json.optJSONObject("user");
if (finalSettingsOk && settingsPayload != null) {
preferredEntryTab = settingsPayload.optString("preferredEntryPoint", "conversations");
}
if (finalSettingsOk) {
updateBoundDeviceState(userPayload);
}
maybeApplyPreferredEntry();
renderCurrentTab();
startRefreshing(false);
if (!finalConversationsOk || !finalDevicesOk || !finalOtaOk || !finalSettingsOk) {
showMessage("刷新失败,请稍后重试");
}
});
} catch (Exception error) {
JSONObject finalSession = session;
runOnUiThread(() -> {
sessionData = finalSession == null ? sessionData : finalSession;
maybeApplyPreferredEntry();
renderCurrentTab();
startRefreshing(false);
showMessage("刷新失败,请稍后重试");
});
}
});
}
@@ -248,7 +338,7 @@ public class MainActivity extends AppCompatActivity {
private void setLoginLoading(boolean loading, String hint) {
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
loginButton.setEnabled(!loading);
loginButton.setText(loading ? "处理中..." : "登录");
loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel());
loginHint.setText(hint);
}
@@ -256,18 +346,18 @@ public class MainActivity extends AppCompatActivity {
activeTab = tab;
if (fromUser) {
userSelectedTab = true;
persistLastRootTab(tab);
}
lastRootBackPressedAt = 0L;
updateTabStyles();
renderCurrentTab();
}
private void maybeApplyPreferredEntry() {
if (explicitTabRequest || userSelectedTab) {
if (userSelectedTab) {
return;
}
if ("devices".equals(preferredEntryTab) || "me".equals(preferredEntryTab) || "conversations".equals(preferredEntryTab)) {
activeTab = preferredEntryTab;
}
activeTab = RootTabMemory.resolveInitialTab(requestedInitialTab, readLastRootTab(), preferredEntryTab);
}
private void renderCurrentTab() {
@@ -277,16 +367,19 @@ public class MainActivity extends AppCompatActivity {
switch (activeTab) {
case "devices":
updateHeader("设备", "只展示当前正式接入生产链路的设备");
updateHeader("设备", "这里管理已接入设备与账号状态");
configureTopAction("+添加", true);
renderDevicesRoot();
break;
case "me":
updateHeader("我的", "账号、安全、技能、运维、OTA 都从这里进入。");
updateHeader("我的", "");
configureTopAction("刷新", false);
renderMeRoot();
break;
case "conversations":
default:
updateHeader("会话", "原生会话列表直接消费 /api/v1/conversations。");
updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle());
configureTopAction("刷新", false);
renderConversationsRoot();
break;
}
@@ -295,6 +388,7 @@ public class MainActivity extends AppCompatActivity {
private void updateHeader(String title, String subtitle) {
topTitle.setText(title);
topSubtitle.setText(subtitle);
topSubtitle.setVisibility(subtitle == null || subtitle.isEmpty() ? View.GONE : View.VISIBLE);
}
private void updateTabStyles() {
@@ -304,19 +398,37 @@ public class MainActivity extends AppCompatActivity {
}
private void styleTab(Button button, boolean active) {
button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green));
button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive);
button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted));
}
private void configureTopAction(String label, boolean primaryStyle) {
refreshButton.setText(label);
refreshButton.setBackgroundResource(primaryStyle ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
refreshButton.setTextColor(getColor(primaryStyle ? R.color.boss_surface : R.color.boss_green));
}
private void syncTopActionVisualState(boolean refreshing) {
if ("devices".equals(activeTab)) {
configureTopAction("+添加", true);
refreshButton.setEnabled(true);
return;
}
configureTopAction(refreshing ? "同步中" : "刷新", false);
refreshButton.setEnabled(!refreshing);
}
private void handleTopAction() {
if ("devices".equals(activeTab)) {
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
return;
}
refreshCurrentTab();
}
private void renderConversationsRoot() {
screenContent.removeAllViews();
screenContent.addView(BossUi.buildCard(
this,
"会话首页",
"当前原生首页会直接进入项目详情、目标、版本、转发与线程预算详情。",
conversationsData == null ? "正在等待数据" : "会话数 " + conversationsData.length()
));
screenContent.addView(BossUi.buildHintPill(this, WechatSurfaceMapper.conversationsHintPillText()));
if (conversationsData == null || conversationsData.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
return;
@@ -326,41 +438,23 @@ public class MainActivity extends AppCompatActivity {
JSONObject item = conversationsData.optJSONObject(i);
if (item == null) continue;
String projectId = item.optString("projectId", "");
String title = item.optString("projectTitle", "未命名会话");
StringBuilder body = new StringBuilder(item.optString("preview", "暂无预览"));
if (item.optInt("activeDeviceCount", 0) > 0) {
body.append("\n设备 ").append(item.optString("deviceNamesPreview", "未标注"));
}
JSONObject budget = item.optJSONObject("contextBudgetIndicator");
String meta = "风险 " + item.optString("riskLevel", "unknown")
+ " · 未读 " + item.optInt("unreadCount", 0)
+ " · " + item.optString("latestReplyLabel", "-");
if (budget != null && budget.optBoolean("visible", false)) {
meta = meta + " · 预算 " + budget.optInt("percent", 0) + "%";
}
screenContent.addView(BossUi.buildCard(this, title, body.toString(), meta, v -> {
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
screenContent.addView(BossUi.buildConversationRow(
this,
row,
v -> {
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
openProject(projectId, title);
String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle;
openProject(projectId, projectName);
}));
}
}
private void renderDevicesRoot() {
screenContent.removeAllViews();
screenContent.addView(BossUi.buildCard(
this,
"设备首页",
"设备详情、技能清单和配对草稿都改为原生页。",
devicesData == null ? "正在等待数据" : "设备数 " + devicesData.length()
));
Button addDeviceButton = BossUi.buildPrimaryButton(this, "添加设备");
addDeviceButton.setOnClickListener(v -> startActivity(new Intent(this, DeviceEnrollmentActivity.class)));
screenContent.addView(addDeviceButton);
if (devicesData == null || devicesData.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。"));
return;
@@ -370,19 +464,19 @@ public class MainActivity extends AppCompatActivity {
JSONObject item = devicesData.optJSONObject(i);
if (item == null) continue;
String deviceId = item.optString("id", "");
String title = item.optString("name", "未命名设备");
String body = item.optString("note", item.optString("endpoint", "暂无设备说明"));
String meta = "状态 " + item.optString("status", "unknown")
+ " · 账号 " + item.optString("account", "-")
+ " · 5h " + item.optInt("quota5h", 0)
+ " · 7d " + item.optInt("quota7d", 0);
screenContent.addView(BossUi.buildCard(this, title, body, meta, v -> {
if (deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
openDevice(deviceId, title);
}));
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
screenContent.addView(BossUi.buildDeviceCard(
this,
row,
v -> {
if (deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
openDevice(deviceId, row.title);
},
null
));
}
}
@@ -394,70 +488,23 @@ public class MainActivity extends AppCompatActivity {
String account = sessionData == null
? apiClient.getAccountLabel()
: sessionData.optString("account", apiClient.getAccountLabel());
String expiresAt = sessionData == null ? "-" : sessionData.optString("expiresAt", "-");
screenContent.addView(BossUi.buildCard(
screenContent.addView(BossUi.buildSimpleProfileHeader(
this,
displayName,
"账号 " + account + "\n当前原生客户端已覆盖会话 / 设备 / 我的一级导航。",
"会话到期 " + expiresAt
"ChatGPT Plus · 主账号",
"主控账号已启用安全保护 · " + account
));
screenContent.addView(BossUi.buildMenuRow(
this,
"账号与安全",
"查看当前会话、登录模式和退出登录。",
null,
v -> startActivity(new Intent(this, SecurityActivity.class))
));
screenContent.addView(BossUi.buildMenuRow(
this,
"设置",
"实时刷新、风险徽标和默认首页。",
null,
v -> startActivity(new Intent(this, SettingsActivity.class))
));
screenContent.addView(BossUi.buildMenuRow(
this,
"运维与修复",
"查看故障、repair ticket、审计请求和能力注册表。",
null,
v -> startActivity(new Intent(this, OpsCenterActivity.class))
));
screenContent.addView(BossUi.buildMenuRow(
this,
"AI 账号",
"管理主 GPT、备用 GPT、Master Codex Node 与 API 容灾。",
null,
v -> startActivity(new Intent(this, AiAccountsActivity.class))
));
screenContent.addView(BossUi.buildMenuRow(
this,
"技能",
"按绑定设备查看 Skill并一键复制调用语句。",
null,
v -> startActivity(new Intent(this, SkillInventoryActivity.class))
));
screenContent.addView(BossUi.buildMenuRow(
this,
"关于",
"查看版本、OTA 状态和当前绑定节点。",
otaData == null ? null : otaData.optBoolean("hasOta", false) ? "OTA" : null,
v -> startActivity(new Intent(this, AboutActivity.class))
));
if (otaData != null) {
JSONObject availableRelease = otaData.optJSONObject("availableRelease");
String body = "当前版本 " + otaData.optString("currentVersion", "-");
String meta = availableRelease == null
? "当前没有待安装版本"
: "可用版本 " + availableRelease.optString("version", "-")
+ " · 文件 " + availableRelease.optString("packageFileName", "-");
screenContent.addView(BossUi.buildCard(this, "OTA 状态", body, meta));
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
screenContent.addView(BossUi.buildWechatMenuRow(
this,
item.title,
item.description,
null,
meBadgeFor(item.key),
v -> openMeEntry(item.key)
));
}
Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录");
logoutButton.setOnClickListener(v -> logout());
screenContent.addView(logoutButton);
}
private void logout() {
@@ -495,11 +542,140 @@ public class MainActivity extends AppCompatActivity {
private void startRefreshing(boolean refreshing) {
screenRefresh.setRefreshing(refreshing);
refreshButton.setEnabled(!refreshing);
refreshButton.setText(refreshing ? "同步中" : "刷新");
syncTopActionVisualState(refreshing);
}
private void showMessage(String text) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
}
private void openMeEntry(String key) {
Intent intent;
switch (key) {
case "security":
intent = new Intent(this, SecurityActivity.class);
break;
case "ai_accounts":
intent = new Intent(this, AiAccountsActivity.class);
break;
case "settings":
intent = new Intent(this, SettingsActivity.class);
break;
case "ops":
intent = new Intent(this, OpsCenterActivity.class);
break;
case "skills":
openSkillInventoryFromMe();
return;
case "about":
intent = new Intent(this, AboutActivity.class);
break;
default:
showMessage("暂未接入:" + key);
return;
}
startActivity(intent);
}
private void openSkillInventoryFromMe() {
String targetDeviceId = resolveSkillTargetDeviceId();
if (targetDeviceId == null || targetDeviceId.isEmpty()) {
showMessage("当前没有可确定的绑定设备,请先到设备页确认。");
return;
}
Intent intent = new Intent(this, SkillInventoryActivity.class);
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, targetDeviceId);
String targetDeviceName = resolveDeviceName(targetDeviceId);
if (targetDeviceName != null && !targetDeviceName.isEmpty()) {
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, targetDeviceName);
}
startActivity(intent);
}
private void updateBoundDeviceState(@Nullable JSONObject userPayload) {
String nextBoundDeviceId = userPayload == null ? "" : userPayload.optString("boundDeviceId", "");
boundDeviceId = nextBoundDeviceId.isEmpty() ? null : nextBoundDeviceId;
boundDeviceName = resolveDeviceName(boundDeviceId);
}
private @Nullable String resolveSkillTargetDeviceId() {
if (devicesData == null || devicesData.length() == 0) {
return null;
}
if (boundDeviceId != null && !boundDeviceId.isEmpty()) {
String validatedBoundDeviceId = findDeviceId(devicesData, boundDeviceId);
if (validatedBoundDeviceId != null) {
return validatedBoundDeviceId;
}
}
String account = sessionData == null ? apiClient.getAccountLabel() : sessionData.optString("account", apiClient.getAccountLabel());
String matchedByAccount = null;
for (int i = 0; i < devicesData.length(); i++) {
JSONObject device = devicesData.optJSONObject(i);
if (device == null) continue;
String deviceAccount = device.optString("account", "");
if (!account.isEmpty() && account.equals(deviceAccount)) {
matchedByAccount = device.optString("id", "");
break;
}
}
if (matchedByAccount != null && !matchedByAccount.isEmpty()) {
return matchedByAccount;
}
if (devicesData.length() == 1) {
JSONObject onlyDevice = devicesData.optJSONObject(0);
return onlyDevice == null ? null : onlyDevice.optString("id", "");
}
JSONObject fallback = devicesData.optJSONObject(0);
return fallback == null ? null : fallback.optString("id", "");
}
private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) {
if (candidateDeviceId == null || candidateDeviceId.isEmpty()) {
return null;
}
for (int i = 0; i < devices.length(); i++) {
JSONObject device = devices.optJSONObject(i);
if (device == null) continue;
if (candidateDeviceId.equals(device.optString("id", ""))) {
return candidateDeviceId;
}
}
return null;
}
private @Nullable String resolveDeviceName(@Nullable String deviceId) {
if (deviceId == null || deviceId.isEmpty() || devicesData == null) {
return null;
}
for (int i = 0; i < devicesData.length(); i++) {
JSONObject device = devicesData.optJSONObject(i);
if (device == null) continue;
if (deviceId.equals(device.optString("id", ""))) {
return device.optString("name", deviceId);
}
}
return boundDeviceName;
}
private @Nullable String meBadgeFor(String key) {
if ("about".equals(key) && otaData != null && otaData.optBoolean("hasOta", false)) {
return "OTA";
}
return null;
}
private void persistLastRootTab(String tab) {
getSharedPreferences(UI_PREFS, MODE_PRIVATE)
.edit()
.putString(KEY_LAST_ROOT_TAB, tab)
.apply();
}
private @Nullable String readLastRootTab() {
return getSharedPreferences(UI_PREFS, MODE_PRIVATE)
.getString(KEY_LAST_ROOT_TAB, null);
}
}

View File

@@ -10,18 +10,12 @@ import org.json.JSONArray;
import org.json.JSONObject;
public class OpsCenterActivity extends BossScreenActivity {
private enum Tab {
OPS,
AUDIT
}
private Tab activeTab = Tab.OPS;
private LinearLayout contentRoot;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("运维中心", "运维对话 / 审计对话");
configureScreen("运维与修复", "运维会话、修复回放与 standby 切换");
setHeaderAction("刷新", v -> reload());
contentRoot = new LinearLayout(this);
contentRoot.setOrientation(LinearLayout.VERTICAL);
@@ -35,62 +29,34 @@ public class OpsCenterActivity extends BossScreenActivity {
executor.execute(() -> {
try {
BossApiClient.ApiResponse ops = apiClient.getOpsSummary();
BossApiClient.ApiResponse audit = apiClient.getAuditSummary();
if (!ops.ok() || !audit.ok()) {
throw new IllegalStateException("OPS_OR_AUDIT_LOAD_FAILED");
if (!ops.ok()) {
throw new IllegalStateException("OPS_LOAD_FAILED");
}
runOnUiThread(() -> render(ops.json, audit.json));
runOnUiThread(() -> render(ops.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "运维中心加载失败:" + error.getMessage()));
replaceContent(BossUi.buildEmptyCard(this, "运维与修复加载失败:" + error.getMessage()));
});
}
});
}
private void render(JSONObject ops, JSONObject audit) {
private void render(JSONObject ops) {
replaceContent(contentRoot);
contentRoot.removeAllViews();
contentRoot.addView(buildTabBar());
if (activeTab == Tab.OPS) {
renderOpsTab(ops);
} else {
renderAuditTab(audit);
}
renderOpsTab(ops);
setRefreshing(false);
}
private LinearLayout buildTabBar() {
LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.addView(buildTabButton("运维对话", activeTab == Tab.OPS, v -> {
activeTab = Tab.OPS;
reload();
}));
bar.addView(buildTabButton("审计对话", activeTab == Tab.AUDIT, v -> {
activeTab = Tab.AUDIT;
reload();
}));
return bar;
}
private Button buildTabButton(String label, boolean active, android.view.View.OnClickListener listener) {
Button button = BossUi.buildPrimaryButton(this, label);
button.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green));
button.setOnClickListener(listener);
return button;
}
private void renderOpsTab(JSONObject ops) {
contentRoot.addView(BossUi.buildCard(
contentRoot.addView(BossUi.buildSoftPanel(
this,
"当前巡检模式",
"巡检状态",
ops.optString("mode", "idle").equals("active")
? "active当前存在风险线程或未关闭运维工单。"
: "idle当前没有高风险工单保持低频巡检。",
"来源:/api/v1/ops/summary"
"这里只保留修复与验证的轻量入口。"
));
JSONArray faults = ops.optJSONArray("faults");
@@ -106,7 +72,9 @@ public class OpsCenterActivity extends BossScreenActivity {
}
private LinearLayout buildFaultCard(JSONObject fault, @Nullable JSONArray tickets) {
LinearLayout card = BossUi.buildCard(
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
fault.optString("faultKey", "故障"),
fault.optString("summary", "暂无摘要"),
@@ -114,13 +82,9 @@ public class OpsCenterActivity extends BossScreenActivity {
+ " · " + fault.optString("status", "-")
+ " · " + fault.optString("nodeId", "-")
+ " · " + fault.optString("serviceName", "-")
);
card.addView(BossUi.buildCard(
this,
"建议动作",
fault.optString("suggestedNextAction", "暂无"),
"trace " + fault.optString("traceId", "-")
+ " · 建议 " + fault.optString("suggestedNextAction", "暂无"),
null,
null
));
if (tickets != null) {
@@ -135,122 +99,38 @@ public class OpsCenterActivity extends BossScreenActivity {
}
private LinearLayout buildTicketCard(JSONObject ticket) {
LinearLayout card = BossUi.buildCard(
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
ticket.optString("title", "修复工单"),
ticket.optString("actionSummary", "暂无动作摘要"),
ticket.optString("approvalStatus", "-")
+ " · " + ticket.optString("executionStatus", "-")
+ " · " + ticket.optString("targetNodeId", "-")
);
+ " · " + ticket.optString("updatedAt", "-"),
null,
null
));
if (ticket.optJSONObject("verification") != null) {
JSONObject verification = ticket.optJSONObject("verification");
card.addView(BossUi.buildCard(
card.addView(BossUi.buildWechatMenuRow(
this,
"验证结果",
verification.optString("summary", "暂无"),
verification.optString("status", "-")
+ " · " + verification.optString("verifiedAt", "-")
+ " · " + verification.optString("verifiedAt", "-"),
null,
null
));
}
Button approve = BossUi.buildPrimaryButton(this, "批准修复");
Button approve = BossUi.buildMiniActionButton(this, "批准修复", true);
approve.setOnClickListener(v -> approveTicket(ticket.optString("ticketId")));
card.addView(approve);
Button verify = BossUi.buildSecondaryButton(this, "验证修复");
Button verify = BossUi.buildMiniActionButton(this, "验证修复", false);
verify.setOnClickListener(v -> verifyTicket(ticket.optString("ticketId")));
card.addView(verify);
return card;
}
private void renderAuditTab(JSONObject audit) {
contentRoot.addView(BossUi.buildCard(
this,
"审计概要",
"待处理请求 " + (audit.optJSONArray("pendingRequests") == null ? 0 : audit.optJSONArray("pendingRequests").length())
+ "\n最新结果 " + (audit.optJSONArray("latestResults") == null ? 0 : audit.optJSONArray("latestResults").length()),
"来源:/api/v1/audits/summary"
));
JSONArray pendingRequests = audit.optJSONArray("pendingRequests");
if (pendingRequests == null || pendingRequests.length() == 0) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待处理的审计请求。"));
} else {
for (int i = 0; i < pendingRequests.length(); i++) {
JSONObject request = pendingRequests.optJSONObject(i);
if (request == null) continue;
contentRoot.addView(buildAuditRequestCard(request));
}
}
JSONArray latestResults = audit.optJSONArray("latestResults");
if (latestResults != null && latestResults.length() > 0) {
contentRoot.addView(BossUi.buildCard(this, "审计结果", "最近完成的审计会展示在这里。", "可回看 decision / findings"));
for (int i = 0; i < latestResults.length(); i++) {
JSONObject result = latestResults.optJSONObject(i);
if (result == null) continue;
contentRoot.addView(buildAuditResultCard(result));
}
}
JSONArray capabilities = audit.optJSONArray("capabilities");
if (capabilities != null && capabilities.length() > 0) {
contentRoot.addView(BossUi.buildCard(this, "能力注册表", "展示当前设备上的可用能力。", "与审计请求的 capabilityRequirements 对应"));
for (int i = 0; i < capabilities.length(); i++) {
JSONObject capability = capabilities.optJSONObject(i);
if (capability == null) continue;
contentRoot.addView(BossUi.buildCard(
this,
capability.optString("displayName", "能力"),
capability.optString("capabilityType", "-")
+ "\n提供者" + capability.optString("providerId", "-")
+ "\n模式" + capability.optString("leaseMode", "-")
+ "\n动作" + joinArray(capability.optJSONArray("supportedActions")),
capability.optString("status", "-")
+ " · " + capability.optString("healthStatus", "-")
+ " · " + capability.optString("nodeId", "-")
));
}
}
}
private LinearLayout buildAuditRequestCard(JSONObject request) {
LinearLayout card = BossUi.buildCard(
this,
request.optString("projectName", "审计请求"),
request.optString("objective", "暂无目标"),
request.optString("auditType", "-")
+ " · priority " + request.optInt("priority", 0)
+ " · " + request.optString("trigger", "-")
);
card.addView(BossUi.buildCard(
this,
"审计条件",
"要求:" + joinStringArray(request.optJSONArray("acceptanceCriteria"))
+ "\n风险" + joinStringArray(request.optJSONArray("riskFocus"))
+ "\n证据" + joinStringArray(request.optJSONArray("evidenceRefs")),
"时限 " + request.optInt("timeBudgetSeconds", 0) + ""
));
return card;
}
private LinearLayout buildAuditResultCard(JSONObject result) {
LinearLayout card = BossUi.buildCard(
this,
result.optString("decision", "result"),
result.optString("summary", "暂无摘要"),
result.optString("status", "-")
+ " · confidence " + result.optDouble("confidence", 0.0)
+ " · " + result.optString("completedAt", "-")
);
card.addView(BossUi.buildCard(
this,
"审计发现",
joinStringArray(result.optJSONArray("findings")),
"需要动作:" + joinStringArray(result.optJSONArray("requiredActions"))
));
card.addView(BossUi.buildInlineActionRow(this, approve, verify));
return card;
}
@@ -311,16 +191,4 @@ public class OpsCenterActivity extends BossScreenActivity {
}
return builder.length() == 0 ? "-" : builder.toString();
}
private String joinStringArray(@Nullable JSONArray values) {
if (values == null || values.length() == 0) return "-";
StringBuilder builder = new StringBuilder();
for (int i = 0; i < values.length(); i++) {
String value = values.optString(i);
if (value == null || value.isEmpty()) continue;
if (builder.length() > 0) builder.append("");
builder.append(value);
}
return builder.length() == 0 ? "-" : builder.toString();
}
}

View File

@@ -0,0 +1,114 @@
package com.hyzq.boss;
public final class OtaDownloadStateMapper {
public enum ActionKind {
NONE,
RETRY_DOWNLOAD,
OPEN_INSTALL_PERMISSION,
INSTALL_APK
}
public static final class UiState {
public final String title;
public final String subtitle;
public final String meta;
public final String badge;
public final String actionLabel;
public final ActionKind actionKind;
public UiState(
String title,
String subtitle,
String meta,
String badge,
String actionLabel,
ActionKind actionKind
) {
this.title = title;
this.subtitle = subtitle;
this.meta = meta;
this.badge = badge;
this.actionLabel = actionLabel;
this.actionKind = actionKind;
}
}
private OtaDownloadStateMapper() {}
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
if (!hasKnownTotal) {
return "正在准备下载";
}
int safePercent = Math.max(0, Math.min(100, percent));
return "已下载 " + safePercent + "%";
}
public static UiState active(String fileName, int percent, boolean hasKnownTotal, long bytesDownloaded, long totalBytes) {
return new UiState(
"安装包下载中",
toProgressLabel(percent, hasKnownTotal),
buildMeta(fileName, bytesDownloaded, totalBytes),
"NOW",
null,
ActionKind.NONE
);
}
public static UiState failed(String fileName) {
return new UiState(
"安装包下载失败",
"下载未成功完成,可以直接重试",
fileName,
"FAIL",
"重试下载",
ActionKind.RETRY_DOWNLOAD
);
}
public static UiState waitingInstallPermission(String fileName) {
return new UiState(
"等待安装授权",
"请先允许 Boss 安装未知来源应用",
fileName,
"STEP",
"前往授权",
ActionKind.OPEN_INSTALL_PERMISSION
);
}
public static UiState readyToInstall(String fileName) {
return new UiState(
"安装包已就绪",
"下载完成,可继续拉起系统安装",
fileName,
"DONE",
"继续安装",
ActionKind.INSTALL_APK
);
}
private static String buildMeta(String fileName, long bytesDownloaded, long totalBytes) {
if (bytesDownloaded <= 0 && totalBytes <= 0) {
return fileName;
}
StringBuilder builder = new StringBuilder(fileName);
builder.append(" · ").append(formatBytes(bytesDownloaded));
if (totalBytes > 0) {
builder.append(" / ").append(formatBytes(totalBytes));
}
return builder.toString();
}
private static String formatBytes(long bytes) {
if (bytes <= 0) {
return "0 B";
}
if (bytes < 1024) {
return bytes + " B";
}
if (bytes < 1024L * 1024L) {
return String.format(java.util.Locale.US, "%.1f KB", bytes / 1024.0d);
}
return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0d * 1024.0d));
}
}

View File

@@ -0,0 +1,176 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public final class ProjectChatUiState {
private ProjectChatUiState() {}
public static final class SelectionState {
public final boolean multiSelecting;
public final Set<String> selectedMessageIds;
private SelectionState(Set<String> selectedMessageIds) {
LinkedHashSet<String> normalizedIds = new LinkedHashSet<>(selectedMessageIds);
this.multiSelecting = !normalizedIds.isEmpty();
this.selectedMessageIds = Collections.unmodifiableSet(normalizedIds);
}
}
public static final class ChromeState {
public final boolean multiSelecting;
public final boolean showComposer;
public final boolean showMultiSelectBar;
public final boolean showRefresh;
public final boolean showHeaderAction;
public final boolean forwardEnabled;
public final String backLabel;
public final String title;
public final String subtitle;
private ChromeState(
boolean multiSelecting,
boolean showComposer,
boolean showMultiSelectBar,
boolean showRefresh,
boolean showHeaderAction,
boolean forwardEnabled,
String backLabel,
String title,
String subtitle
) {
this.multiSelecting = multiSelecting;
this.showComposer = showComposer;
this.showMultiSelectBar = showMultiSelectBar;
this.showRefresh = showRefresh;
this.showHeaderAction = showHeaderAction;
this.forwardEnabled = forwardEnabled;
this.backLabel = backLabel;
this.title = title;
this.subtitle = subtitle;
}
}
public static boolean canSend(String text, boolean sending) {
return !sending && text != null && !text.trim().isEmpty();
}
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
return nearBottom || forced;
}
public static SelectionState emptySelection() {
return new SelectionState(new LinkedHashSet<>());
}
public static SelectionState selectOnly(String messageId) {
return toggleSelection(emptySelection(), messageId);
}
public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {
if (messageId == null || messageId.trim().isEmpty()) {
throw new IllegalArgumentException("messageId must not be blank");
}
SelectionState state = current == null ? emptySelection() : current;
LinkedHashSet<String> selectedMessageIds = new LinkedHashSet<>(state.selectedMessageIds);
if (selectedMessageIds.contains(messageId)) {
selectedMessageIds.remove(messageId);
return new SelectionState(selectedMessageIds);
}
selectedMessageIds.add(messageId);
return new SelectionState(selectedMessageIds);
}
public static boolean canForwardSelection(@Nullable SelectionState state) {
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
}
public static SelectionState reconcileSelection(
@Nullable SelectionState current,
@Nullable List<String> availableMessageIds
) {
if (current == null || current.selectedMessageIds.isEmpty() || availableMessageIds == null || availableMessageIds.isEmpty()) {
return emptySelection();
}
LinkedHashSet<String> available = new LinkedHashSet<>(availableMessageIds);
LinkedHashSet<String> selected = new LinkedHashSet<>();
for (String selectedMessageId : current.selectedMessageIds) {
if (available.contains(selectedMessageId)) {
selected.add(selectedMessageId);
}
}
return new SelectionState(selected);
}
public static ChromeState resolveChromeState(
@Nullable SelectionState selectionState,
boolean conversationInfoReady,
@Nullable String defaultTitle,
@Nullable String defaultSubtitle
) {
boolean multiSelecting = selectionState != null && selectionState.multiSelecting;
if (multiSelecting) {
int selectedCount = selectionState.selectedMessageIds.size();
return new ChromeState(
true,
false,
true,
false,
false,
canForwardSelection(selectionState),
"取消",
"已选 " + selectedCount + "",
"选择要转发的消息"
);
}
return new ChromeState(
false,
true,
false,
true,
conversationInfoReady,
false,
"返回",
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
);
}
@Nullable
public static String labelForForwardKind(@Nullable String kind) {
if ("forward_single".equals(kind)) {
return "转发";
}
if ("forward_bundle".equals(kind)) {
return "聊天记录";
}
return null;
}
public static String summarizeForwardBundle(@Nullable String lastBody, int itemCount) {
if (itemCount > 0 && !isBlank(lastBody)) {
return itemCount + " 条消息 · 最后一条:" + truncate(lastBody, 28);
}
if (itemCount > 0) {
return itemCount + " 条消息";
}
return truncate(lastBody, 28);
}
private static boolean isBlank(@Nullable String value) {
return value == null || value.trim().isEmpty();
}
private static String truncate(@Nullable String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() <= maxLength) {
return normalized;
}
return normalized.substring(0, maxLength) + "";
}
}

View File

@@ -1,42 +1,182 @@
package com.hyzq.boss;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.os.Bundle;
import android.view.ViewGroup;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public class ProjectDetailActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private String projectId;
private String initialProjectName;
private boolean projectIsGroup;
private String projectFolderName;
private LinearLayout quickActionsLayout;
private LinearLayout composerRow;
private LinearLayout multiSelectActionsLayout;
private EditText composerInput;
private Button composerSendButton;
private Button multiSelectForwardButton;
private ScrollView chatScrollView;
private View pendingOutgoingBubble;
private boolean composerSending;
private boolean renderNearBottom;
private boolean renderForcedScrollToBottom;
private boolean conversationInfoReady;
private String currentScreenTitle;
private String currentScreenSubtitle;
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
private ActivityResultLauncher<Intent> conversationInfoLauncher;
private ActivityResultLauncher<Intent> forwardTargetLauncher;
static final class ChromeBindings {
final boolean multiSelecting;
final boolean showComposer;
final boolean showMultiSelectBar;
final boolean showRefresh;
final boolean showHeaderAction;
final boolean enableForwardButton;
final boolean enablePullRefresh;
final String backLabel;
final String title;
final String subtitle;
ChromeBindings(
boolean multiSelecting,
boolean showComposer,
boolean showMultiSelectBar,
boolean showRefresh,
boolean showHeaderAction,
boolean enableForwardButton,
boolean enablePullRefresh,
String backLabel,
String title,
String subtitle
) {
this.multiSelecting = multiSelecting;
this.showComposer = showComposer;
this.showMultiSelectBar = showMultiSelectBar;
this.showRefresh = showRefresh;
this.showHeaderAction = showHeaderAction;
this.enableForwardButton = enableForwardButton;
this.enablePullRefresh = enablePullRefresh;
this.backLabel = backLabel;
this.title = title;
this.subtitle = subtitle;
}
}
@Override
protected int getLayoutResId() {
return R.layout.activity_project_chat;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
initialProjectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
setHeaderAction("发消息", v -> chooseMessageKindAndSend());
reload();
quickActionsLayout = findViewById(R.id.project_chat_quick_actions);
composerRow = findViewById(R.id.project_chat_composer_row);
multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions);
composerInput = findViewById(R.id.project_chat_input);
composerSendButton = findViewById(R.id.project_chat_send);
multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward);
chatScrollView = findViewById(R.id.project_chat_scroll);
conversationInfoLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() != RESULT_OK) {
return;
}
Intent data = result.getData();
if (data != null) {
String updatedTitle = data.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME);
if (!TextUtils.isEmpty(updatedTitle)) {
initialProjectName = updatedTitle;
updateProjectHeader(updatedTitle, "正在同步项目详情...");
}
}
reload();
}
);
forwardTargetLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() != RESULT_OK) {
return;
}
exitMultiSelect();
reload(true);
}
);
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
multiSelectForwardButton.setOnClickListener(v -> {
if (!ProjectChatUiState.canForwardSelection(selectionState)) {
showMessage("至少选择两条消息后才能合并转发");
return;
}
openBundleForwardTarget(new ArrayList<>(selectionState.selectedMessageIds));
});
composerInput.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
updateComposerSendButtonState();
}
@Override
public void afterTextChanged(Editable s) {}
});
updateComposerSendButtonState();
updateSelectionUi();
if (shouldLoadOnCreate()) {
reload(true);
}
}
boolean shouldLoadOnCreate() {
return true;
}
@Override
protected void reload() {
reload(false);
}
private void reload(boolean forcedScrollToBottom) {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
finish();
return;
}
renderNearBottom = isChatNearBottom();
renderForcedScrollToBottom = forcedScrollToBottom;
setRefreshing(true);
executor.execute(() -> {
try {
@@ -48,194 +188,140 @@ public class ProjectDetailActivity extends BossScreenActivity {
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
composerSending = false;
updateComposerSendButtonState();
if (pendingOutgoingBubble == null) {
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
} else {
showMessage("项目详情刷新失败:" + error.getMessage());
}
});
}
});
}
@Override
protected void setRefreshing(boolean refreshing) {
super.setRefreshing(refreshing);
if (composerInput != null) {
composerInput.setEnabled(!refreshing);
}
updateComposerSendButtonState();
updateSelectionUi();
}
private void renderProject(JSONObject payload) {
JSONObject project = payload.optJSONObject("project");
JSONArray devices = payload.optJSONArray("devices");
JSONArray threadContexts = payload.optJSONArray("activeThreadContexts");
JSONArray recentLogs = payload.optJSONArray("recentAppLogs");
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
String title = project != null ? project.optString("name", "项目详情") : "项目详情";
String subtitle = "设备:" + joinDeviceNames(devices);
configureScreen(title, subtitle);
initialProjectName = title;
projectIsGroup = project != null && project.optBoolean("isGroup", false);
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
conversationInfoReady = project != null;
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
renderQuickActions();
replaceContent();
appendContent(buildActionGrid());
JSONObject masterIdentity = payload.optJSONObject("masterIdentity");
if (masterIdentity != null) {
String body = masterIdentity.optString("roleLabel", "主控")
+ " · " + masterIdentity.optString("displayName", "-")
+ (masterIdentity.optString("nodeLabel").isEmpty() ? "" : " · " + masterIdentity.optString("nodeLabel"))
+ (masterIdentity.optString("model").isEmpty() ? "" : "\n模型 " + masterIdentity.optString("model"));
String meta = masterIdentity.optString("statusLabel", "")
+ (masterIdentity.optString("lastSwitchedAt").isEmpty() ? "" : " · 最近切换 " + masterIdentity.optString("lastSwitchedAt"));
appendContent(BossUi.buildCard(this, "当前主控身份", body, meta));
}
appendContent(BossUi.buildCard(
this,
"主 Agent 调度结论",
payload.optString("masterContextStrategySummary", "暂无调度摘要。"),
"原生项目详情已接入 /api/v1/projects/{projectId}"
));
if (threadContexts != null && threadContexts.length() > 0) {
for (int i = 0; i < threadContexts.length(); i++) {
JSONObject thread = threadContexts.optJSONObject(i);
if (thread == null) continue;
JSONObject snapshot = thread.optJSONObject("snapshot");
if (snapshot == null) continue;
String threadId = snapshot.optString("threadId");
String body = snapshot.optString("summary", "暂无摘要");
String meta = snapshot.optString("workerId", "-")
+ " · " + snapshot.optString("nodeId", "-")
+ " · " + snapshot.optInt("contextBudgetRemainingPct", 0) + "%"
+ " · " + snapshot.optString("contextBudgetLevel", "safe");
appendContent(BossUi.buildCard(
this,
snapshot.optString("title", "线程详情"),
body,
meta,
v -> openThread(threadId)
));
}
} else {
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有线程预算数据。"));
}
if (recentLogs != null && recentLogs.length() > 0) {
for (int i = 0; i < recentLogs.length(); i++) {
JSONObject log = recentLogs.optJSONObject(i);
if (log == null) continue;
String body = log.optString("message", "无消息体");
if (!log.optString("detail").isEmpty()) {
body = body + "\n" + log.optString("detail");
}
String meta = log.optString("deviceId", "-")
+ " · " + log.optString("category", "-")
+ " · " + log.optString("createdAt", "-");
appendContent(BossUi.buildCard(this, "实时 APP 日志", body, meta));
}
}
pendingOutgoingBubble = null;
JSONArray messages = project == null ? null : project.optJSONArray("messages");
selectionState = ProjectChatUiState.reconcileSelection(selectionState, collectMessageIds(messages));
if (messages != null && messages.length() > 0) {
for (int i = 0; i < messages.length(); i++) {
JSONObject message = messages.optJSONObject(i);
if (message == null) continue;
String meta = message.optString("sentAt", "-")
+ (message.optString("kind").isEmpty() ? "" : " · " + message.optString("kind"));
appendContent(BossUi.buildCard(
this,
message.optString("senderLabel", "消息"),
message.optString("body", ""),
meta
));
if (message == null) {
continue;
}
appendContent(buildMessageView(message));
}
} else {
appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。"));
}
appendContent(BossUi.buildCard(
this,
"媒体与转发说明",
"语音、图片、视频与转发现在都通过原生入口触发,并写回现有 Boss 消息账本。",
"对象存储与真实媒体文件仍保持 MVP 占位。"
));
setRefreshing(false);
updateSelectionUi();
if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, renderForcedScrollToBottom)) {
scrollChatToBottom();
}
}
private LinearLayout buildActionGrid() {
LinearLayout wrapper = new LinearLayout(this);
wrapper.setOrientation(LinearLayout.VERTICAL);
wrapper.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
wrapper.addView(buildActionRow(
buildActionButton("发送消息", v -> chooseMessageKindAndSend()),
buildActionButton("项目目标", v -> openGoals())
));
wrapper.addView(buildActionRow(
buildActionButton("版本记录", v -> openVersions()),
buildActionButton("消息转发", v -> openForward())
));
return wrapper;
private void renderQuickActions() {
if (quickActionsLayout == null) {
return;
}
quickActionsLayout.removeAllViews();
String[] actions = WechatSurfaceMapper.projectQuickActions();
for (int i = 0; i < actions.length; i++) {
String action = actions[i];
Button button = buildQuickActionButton(action, i == 0);
if ("项目目标".equals(action)) {
button.setOnClickListener(v -> openGoals());
} else if ("版本记录".equals(action)) {
button.setOnClickListener(v -> openVersions());
}
quickActionsLayout.addView(button);
}
}
private LinearLayout buildActionRow(Button left, Button right) {
LinearLayout row = new LinearLayout(this);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = BossUi.dp(this, 12);
row.setLayoutParams(params);
row.setOrientation(LinearLayout.HORIZONTAL);
LinearLayout.LayoutParams childParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
childParams.rightMargin = BossUi.dp(this, 6);
left.setLayoutParams(childParams);
LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
rightParams.leftMargin = BossUi.dp(this, 6);
right.setLayoutParams(rightParams);
row.addView(left);
row.addView(right);
return row;
}
private Button buildActionButton(String label, android.view.View.OnClickListener listener) {
Button button = BossUi.buildPrimaryButton(this, label);
button.setOnClickListener(listener);
private Button buildQuickActionButton(String label, boolean highlight) {
Button button = new Button(this);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, BossUi.dp(this, 40), 1f);
if (quickActionsLayout.getChildCount() > 0) {
params.leftMargin = BossUi.dp(this, 8);
}
button.setLayoutParams(params);
button.setMinWidth(0);
button.setText(label);
button.setTextSize(14);
button.setAllCaps(false);
button.setPadding(BossUi.dp(this, 12), 0, BossUi.dp(this, 12), 0);
button.setBackgroundResource(highlight ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
button.setTextColor(getColor(highlight ? R.color.boss_surface : R.color.boss_text_primary));
return button;
}
private void chooseMessageKindAndSend() {
final String[] labels = {"文本消息", "语音意图", "图片意图", "视频意图"};
final String[] kinds = {"text", "voice_intent", "image_intent", "video_intent"};
new AlertDialog.Builder(this)
.setTitle("选择消息类型")
.setItems(labels, (dialog, which) -> showSendDialog(kinds[which], labels[which]))
.setNegativeButton("取消", null)
.show();
}
private void showSendDialog(String kind, String label) {
final android.widget.EditText input = BossUi.buildInput(this, "请输入要发送给项目的内容", true);
new AlertDialog.Builder(this)
.setTitle("发送" + label)
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("发送", (dialog, which) -> sendProjectMessage(kind, input.getText().toString().trim()))
.show();
}
private void sendProjectMessage(String kind, String body) {
private void sendTextMessageFromComposer() {
if (composerInput == null) {
return;
}
String body = composerInput.getText() == null ? "" : composerInput.getText().toString().trim();
if (body.isEmpty()) {
showMessage("请输入消息内容");
return;
}
if (!ProjectChatUiState.canSend(body, isComposerBusy())) {
return;
}
sendProjectMessage("text", body);
}
private void sendProjectMessage(String kind, String body) {
composerSending = true;
updateComposerSendButtonState();
setRefreshing(true);
appendPendingOutgoingBubble(body);
scrollChatToBottom();
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind);
if (!response.ok()) throw new IllegalStateException(response.message());
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
setRefreshing(false);
composerSending = false;
composerInput.setText("");
showMessage("消息已发送");
reload();
reload(true);
});
} catch (Exception error) {
runOnUiThread(() -> {
composerSending = false;
setRefreshing(false);
removePendingOutgoingBubble();
showMessage("发送失败:" + error.getMessage());
updateComposerSendButtonState();
});
}
});
@@ -255,18 +341,228 @@ public class ProjectDetailActivity extends BossScreenActivity {
startActivity(intent);
}
private void openForward() {
Intent intent = new Intent(this, ProjectForwardActivity.class);
intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_ID, projectId);
intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_NAME, initialProjectName);
startActivity(intent);
private void openConversationInfo() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, WechatSurfaceMapper.resolveConversationInfoTargetClass(projectIsGroup));
intent.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, projectId);
intent.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, initialProjectName);
conversationInfoLauncher.launch(intent);
}
private void openThread(String threadId) {
Intent intent = new Intent(this, ThreadDetailActivity.class);
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
intent.putExtra(ThreadDetailActivity.EXTRA_PROJECT_ID, projectId);
startActivity(intent);
private View buildMessageView(JSONObject message) {
String messageId = message.optString("id", "");
String senderLabel = message.optString("senderLabel", "消息");
String body = message.optString("body", "");
String meta = formatMessageTime(message.optString("sentAt", ""));
String kind = message.optString("kind", "");
boolean outgoing = isOutgoingMessage(senderLabel);
View messageView;
switch (kind) {
case "forward_single":
messageView = BossUi.buildForwardSingleBubble(
this,
senderLabel,
body,
meta,
resolveForwardSingleSourceLabel(message),
outgoing
);
break;
case "forward_bundle":
messageView = BossUi.buildForwardBundleCard(
this,
senderLabel,
resolveForwardBundleTitle(message),
resolveForwardBundleSummary(message),
meta,
outgoing
);
break;
default:
messageView = BossUi.buildMessageBubble(
this,
senderLabel,
body,
meta,
outgoing,
labelForMessageKind(kind)
);
break;
}
bindMessageInteractions(messageView, messageId, body);
return messageView;
}
private void bindMessageInteractions(View messageView, String messageId, String body) {
if (messageView == null || TextUtils.isEmpty(messageId)) {
return;
}
messageView.setTag(messageId);
messageView.setClickable(true);
messageView.setLongClickable(true);
messageView.setOnClickListener(v -> {
if (!selectionState.multiSelecting) {
return;
}
toggleMultiSelectMessage(messageId);
});
messageView.setOnLongClickListener(v -> {
showMessageActions(messageId, body);
return true;
});
BossUi.applyMessageSelectionState(
this,
messageView,
selectionState.selectedMessageIds.contains(messageId)
);
}
private void showMessageActions(String messageId, String body) {
new AlertDialog.Builder(this)
.setTitle("消息操作")
.setItems(new CharSequence[]{"转发", "多选", "复制", "删除", "取消"}, (dialog, which) -> {
switch (which) {
case 0:
openSingleForwardTarget(messageId);
break;
case 1:
enterMultiSelectFromMessage(messageId);
break;
case 2:
copyMessageBody(body);
break;
case 3:
showMessage("删除消息能力暂未接通");
break;
default:
dialog.dismiss();
break;
}
})
.show();
}
private void copyMessageBody(String body) {
ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
if (clipboard == null) {
showMessage("当前设备不支持复制");
return;
}
clipboard.setPrimaryClip(ClipData.newPlainText("boss-message", TextUtils.isEmpty(body) ? "" : body));
showMessage("已复制消息");
}
private void openSingleForwardTarget(String sourceMessageId) {
if (TextUtils.isEmpty(sourceMessageId)) {
showMessage("缺少消息 ID");
return;
}
Intent intent = new Intent(this, ForwardTargetActivity.class);
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single");
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_MESSAGE_ID, sourceMessageId);
forwardTargetLauncher.launch(intent);
}
private void openBundleForwardTarget(List<String> sourceMessageIds) {
if (sourceMessageIds == null || sourceMessageIds.size() < 2) {
showMessage("至少选择两条消息后才能合并转发");
return;
}
Intent intent = new Intent(this, ForwardTargetActivity.class);
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "bundle");
intent.putExtra(
ForwardTargetActivity.EXTRA_SOURCE_MESSAGE_IDS,
sourceMessageIds.toArray(new String[0])
);
forwardTargetLauncher.launch(intent);
}
private void enterMultiSelectFromMessage(String messageId) {
selectionState = ProjectChatUiState.selectOnly(messageId);
updateSelectionUi();
}
private void exitMultiSelect() {
selectionState = ProjectChatUiState.emptySelection();
updateSelectionUi();
}
private void toggleMultiSelectMessage(String messageId) {
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(selectionState, messageId);
if (!next.multiSelecting) {
exitMultiSelect();
return;
}
selectionState = next;
updateSelectionUi();
}
private void updateSelectionUi() {
ProjectChatUiState.ChromeState chromeState = ProjectChatUiState.resolveChromeState(
selectionState,
conversationInfoReady,
currentScreenTitle,
currentScreenSubtitle
);
ChromeBindings bindings = buildChromeBindings(chromeState, isComposerBusy());
if (composerRow != null) {
composerRow.setVisibility(bindings.showComposer ? View.VISIBLE : View.GONE);
}
if (multiSelectActionsLayout != null) {
multiSelectActionsLayout.setVisibility(bindings.showMultiSelectBar ? View.VISIBLE : View.GONE);
}
if (multiSelectForwardButton != null) {
multiSelectForwardButton.setEnabled(bindings.enableForwardButton);
}
if (refreshLayout != null) {
refreshLayout.setEnabled(bindings.enablePullRefresh);
}
backButton.setText(bindings.backLabel);
backButton.setOnClickListener(v -> {
if (bindings.multiSelecting) {
exitMultiSelect();
return;
}
finish();
});
refreshButton.setVisibility(bindings.showRefresh ? View.VISIBLE : View.GONE);
titleView.setText(bindings.title);
subtitleView.setText(bindings.subtitle);
if (bindings.showHeaderAction) {
setHeaderAction(WechatSurfaceMapper.conversationInfoActionLabel(), v -> openConversationInfo());
} else {
hideHeaderAction();
}
refreshMessageSelectionViews();
}
private void refreshMessageSelectionViews() {
if (contentLayout == null) {
return;
}
for (int i = 0; i < contentLayout.getChildCount(); i++) {
View child = contentLayout.getChildAt(i);
Object tag = child.getTag();
boolean selected = tag instanceof String
&& selectionState != null
&& selectionState.selectedMessageIds.contains(tag);
BossUi.applyMessageSelectionState(this, child, selected);
}
}
private void updateProjectHeader(String title, String subtitle) {
currentScreenTitle = title;
currentScreenSubtitle = subtitle;
if (selectionState != null && selectionState.multiSelecting) {
return;
}
configureScreen(title, subtitle);
}
private String joinDeviceNames(@Nullable JSONArray devices) {
@@ -276,10 +572,197 @@ public class ProjectDetailActivity extends BossScreenActivity {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < devices.length(); i++) {
JSONObject device = devices.optJSONObject(i);
if (device == null) continue;
if (builder.length() > 0) builder.append(" / ");
if (device == null) {
continue;
}
if (builder.length() > 0) {
builder.append(" / ");
}
builder.append(device.optString("name", device.optString("id", "设备")));
}
return builder.length() == 0 ? "未绑定设备" : builder.toString();
}
private String buildProjectSubtitle(String folderName, @Nullable JSONArray devices) {
String deviceLabel = joinDeviceNames(devices);
if (TextUtils.isEmpty(folderName)) {
return "设备:" + deviceLabel;
}
return folderName + " · 设备:" + deviceLabel;
}
private void scrollChatToBottom() {
if (chatScrollView == null) {
return;
}
chatScrollView.post(() -> chatScrollView.fullScroll(View.FOCUS_DOWN));
}
private void appendPendingOutgoingBubble(String body) {
if (contentLayout == null) {
return;
}
removePendingOutgoingBubble();
if (contentLayout.getChildCount() == 1 && contentLayout.getChildAt(0) instanceof android.widget.TextView) {
contentLayout.removeAllViews();
}
String senderLabel = TextUtils.isEmpty(apiClient.getDisplayName()) ? "" : apiClient.getDisplayName();
pendingOutgoingBubble = BossUi.buildPendingOutgoingMessageBubble(this, senderLabel, body);
appendContent(pendingOutgoingBubble);
}
private void removePendingOutgoingBubble() {
if (pendingOutgoingBubble != null && pendingOutgoingBubble.getParent() != null && contentLayout != null) {
contentLayout.removeView(pendingOutgoingBubble);
}
pendingOutgoingBubble = null;
}
private void updateComposerSendButtonState() {
if (composerSendButton == null || composerInput == null) {
return;
}
String body = composerInput.getText() == null ? "" : composerInput.getText().toString();
composerSendButton.setEnabled(ProjectChatUiState.canSend(body, isComposerBusy()));
}
private boolean isComposerBusy() {
return composerSending || (refreshLayout != null && refreshLayout.isRefreshing());
}
private boolean isChatNearBottom() {
if (chatScrollView == null || chatScrollView.getChildCount() == 0 || chatScrollView.getHeight() == 0) {
return true;
}
View child = chatScrollView.getChildAt(0);
if (child == null || child.getHeight() == 0) {
return true;
}
int remainingScroll = child.getBottom() - (chatScrollView.getScrollY() + chatScrollView.getHeight());
return remainingScroll <= BossUi.dp(this, 96);
}
private boolean isOutgoingMessage(String senderLabel) {
if (TextUtils.isEmpty(senderLabel)) {
return false;
}
return "".equals(senderLabel)
|| senderLabel.equals(apiClient.getDisplayName())
|| senderLabel.equals(apiClient.getAccountLabel());
}
private String formatMessageTime(String sentAt) {
if (TextUtils.isEmpty(sentAt)) {
return "";
}
int timeSeparator = sentAt.indexOf('T');
if (timeSeparator >= 0 && sentAt.length() >= timeSeparator + 6) {
return sentAt.substring(timeSeparator + 1, timeSeparator + 6);
}
int blankIndex = sentAt.indexOf(' ');
if (blankIndex >= 0 && sentAt.length() >= blankIndex + 6) {
return sentAt.substring(blankIndex + 1, blankIndex + 6);
}
return sentAt;
}
private String resolveForwardSingleSourceLabel(JSONObject message) {
JSONObject forwardSource = message.optJSONObject("forwardSource");
if (forwardSource == null) {
return "";
}
String threadTitle = forwardSource.optString("sourceThreadTitle", "");
if (!TextUtils.isEmpty(threadTitle)) {
return threadTitle;
}
return forwardSource.optString("sourceProjectName", "");
}
private String resolveForwardBundleTitle(JSONObject message) {
JSONObject forwardBundle = message.optJSONObject("forwardBundle");
if (forwardBundle == null) {
return "聊天记录";
}
String threadTitle = forwardBundle.optString("sourceThreadTitle", "");
if (!TextUtils.isEmpty(threadTitle)) {
return threadTitle;
}
String projectName = forwardBundle.optString("sourceProjectName", "");
return TextUtils.isEmpty(projectName) ? "聊天记录" : projectName;
}
private String resolveForwardBundleSummary(JSONObject message) {
JSONObject forwardBundle = message.optJSONObject("forwardBundle");
if (forwardBundle == null) {
return message.optString("body", "转发的聊天记录");
}
int itemCount = forwardBundle.optInt("itemCount", 0);
JSONArray items = forwardBundle.optJSONArray("items");
JSONObject lastItem = items == null || items.length() == 0 ? null : items.optJSONObject(items.length() - 1);
String lastBody = lastItem == null ? "" : lastItem.optString("body", "");
String summarized = ProjectChatUiState.summarizeForwardBundle(lastBody, itemCount);
if (!TextUtils.isEmpty(summarized)) {
return summarized;
}
return message.optString("body", "转发的聊天记录");
}
static ChromeBindings buildChromeBindings(
ProjectChatUiState.ChromeState chromeState,
boolean composerBusy
) {
return new ChromeBindings(
chromeState.multiSelecting,
chromeState.showComposer,
chromeState.showMultiSelectBar,
chromeState.showRefresh,
chromeState.showHeaderAction,
!composerBusy && chromeState.forwardEnabled,
!chromeState.multiSelecting,
chromeState.backLabel,
chromeState.title,
chromeState.subtitle
);
}
private List<String> collectMessageIds(@Nullable JSONArray messages) {
ArrayList<String> ids = new ArrayList<>();
if (messages == null) {
return ids;
}
for (int i = 0; i < messages.length(); i++) {
JSONObject message = messages.optJSONObject(i);
if (message == null) {
continue;
}
String messageId = message.optString("id", "");
if (!TextUtils.isEmpty(messageId)) {
ids.add(messageId);
}
}
return ids;
}
@Nullable
private String labelForMessageKind(String kind) {
if (TextUtils.isEmpty(kind) || "text".equals(kind)) {
return null;
}
String forwardLabel = ProjectChatUiState.labelForForwardKind(kind);
if (!TextUtils.isEmpty(forwardLabel)) {
return forwardLabel;
}
switch (kind) {
case "voice_intent":
return "语音";
case "image_intent":
return "图片";
case "video_intent":
return "视频";
case "forward_notice":
return "转发";
default:
return kind;
}
}
}

View File

@@ -1,106 +1,30 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
public class ProjectForwardActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private String projectId;
private String projectName;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
configureScreen("消息转发", projectName == null ? "选择目标项目并写备注" : "源项目:" + projectName);
reload();
configureScreen("消息转发", "正在切换到微信式转发");
Intent intent = new Intent(this, ForwardTargetActivity.class);
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single_legacy");
startActivity(intent);
finish();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getConversations();
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> renderTargets(response.json.optJSONArray("conversations")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
});
}
});
}
private void renderTargets(@Nullable JSONArray conversations) {
replaceContent(BossUi.buildCard(
this,
"原生转发入口",
"选择一个目标项目,填写备注后会走现有 `/api/v1/projects/{projectId}/forwards`。",
"源项目:" + (projectName == null ? projectId : projectName)
));
if (conversations == null || conversations.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标项目。"));
setRefreshing(false);
return;
}
for (int i = 0; i < conversations.length(); i++) {
JSONObject item = conversations.optJSONObject(i);
if (item == null) continue;
String targetProjectId = item.optString("projectId");
if (projectId.equals(targetProjectId)) continue;
appendContent(BossUi.buildCard(
this,
item.optString("projectTitle", "未命名项目"),
item.optString("preview", ""),
item.optString("latestReplyLabel", "最近更新"),
v -> openForwardDialog(targetProjectId, item.optString("projectTitle", targetProjectId))
));
}
setRefreshing(false);
}
private void openForwardDialog(String targetProjectId, String targetTitle) {
final android.widget.EditText input = BossUi.buildInput(this, "请输入要附带的转发说明", true);
input.setText("请同步关注 " + targetTitle + " 的当前进展。");
new AlertDialog.Builder(this)
.setTitle("转发到 " + targetTitle)
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("转发", (dialog, which) -> forwardMessage(targetProjectId, input.getText().toString().trim()))
.show();
}
private void forwardMessage(String targetProjectId, String note) {
if (note.isEmpty()) {
showMessage("请先填写转发说明");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(projectId, targetProjectId, note);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
setRefreshing(false);
showMessage("转发成功");
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("转发失败:" + error.getMessage());
});
}
});
// 兼容页只负责跳转,不再承载旧的备注转发链路。
}
}

View File

@@ -0,0 +1,28 @@
package com.hyzq.boss;
public final class RootTabMemory {
private RootTabMemory() {}
public static String resolveInitialTab(String explicitTab, String storedTab, String preferredTab) {
String explicit = normalize(explicitTab);
if (explicit != null) {
return explicit;
}
String stored = normalize(storedTab);
if (stored != null) {
return stored;
}
String preferred = normalize(preferredTab);
if (preferred != null) {
return preferred;
}
return "conversations";
}
private static String normalize(String tab) {
if ("conversations".equals(tab) || "devices".equals(tab) || "me".equals(tab)) {
return tab;
}
return null;
}
}

View File

@@ -11,7 +11,7 @@ public class SecurityActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("账号与安全", "原生会话与设备安全");
configureScreen("账号与安全", "登录会话与设备保护");
reload();
}
@@ -33,33 +33,32 @@ public class SecurityActivity extends BossScreenActivity {
}
private void renderSecurity(@Nullable JSONObject session) {
replaceContent(
BossUi.buildCard(
this,
"当前登录模式",
"当前登录页已临时切到免验证模式,点击登录会直接创建最高管理员会话",
"后续如收口认证,再切回账号密码 / 验证码登录。"
)
);
replaceContent();
appendContent(BossUi.buildSoftPanel(
this,
"当前登录模式",
"当前客户端仍使用快速进入模式",
"需要更严格认证,再切回账号密码验证码登录。"
));
if (session != null) {
appendContent(BossUi.buildCard(
appendContent(BossUi.buildWechatMenuRow(
this,
"当前会话",
"账号 " + session.optString("account", "-")
+ "\n角色 " + session.optString("role", "-")
+ "\n登录方式 " + session.optString("loginMethod", "-"),
"到期 " + session.optString("expiresAt", "-")
+ " · " + session.optString("role", "-"),
"登录方式 " + session.optString("loginMethod", "-")
+ " · 到期 " + session.optString("expiresAt", "-"),
null,
null
));
}
android.widget.Button devicesButton = BossUi.buildPrimaryButton(this, "打开设备页");
devicesButton.setOnClickListener(v -> {
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
Intent intent = new Intent(this, MainActivity.class);
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
startActivity(intent);
});
appendContent(devicesButton);
}));
android.widget.Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录");
logoutButton.setOnClickListener(v -> logout());

View File

@@ -15,13 +15,15 @@ public class SettingsActivity extends BossScreenActivity {
private SwitchCompat riskBadgesSwitch;
private SwitchCompat confirmActionsSwitch;
private Spinner preferredEntrySpinner;
private boolean settingsLoaded = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("设置", "原生偏好配置");
configureScreen("设置", "默认首页与提醒偏好");
setHeaderAction("保存", v -> saveSettings());
buildForm();
buildFormContent();
updateSaveAvailability();
reload();
}
@@ -36,48 +38,52 @@ public class SettingsActivity extends BossScreenActivity {
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
settingsLoaded = false;
updateSaveAvailability();
replaceContent(BossUi.buildEmptyCard(this, "设置加载失败:" + error.getMessage()));
});
}
});
}
private void buildForm() {
replaceContent(
BossUi.buildCard(
this,
"设置说明",
"当前设置会持久化到 data/boss-state.json下一线程接手不会丢失。",
"原生设置页直接走 /api/v1/settings"
)
);
private void buildFormContent() {
if (liveUpdatesSwitch == null) {
liveUpdatesSwitch = new SwitchCompat(this);
liveUpdatesSwitch.setText("启用实时刷新");
}
if (riskBadgesSwitch == null) {
riskBadgesSwitch = new SwitchCompat(this);
riskBadgesSwitch.setText("显示风险徽标");
}
if (confirmActionsSwitch == null) {
confirmActionsSwitch = new SwitchCompat(this);
confirmActionsSwitch.setText("危险操作前确认");
}
if (preferredEntrySpinner == null) {
preferredEntrySpinner = new Spinner(this);
ArrayAdapter<String> adapter = new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"conversations", "devices", "me"}
);
preferredEntrySpinner.setAdapter(adapter);
}
liveUpdatesSwitch = new SwitchCompat(this);
liveUpdatesSwitch.setText("启用实时刷新");
riskBadgesSwitch = new SwitchCompat(this);
riskBadgesSwitch.setText("显示风险徽标");
confirmActionsSwitch = new SwitchCompat(this);
confirmActionsSwitch.setText("危险操作前确认");
preferredEntrySpinner = new Spinner(this);
ArrayAdapter<String> adapter = new ArrayAdapter<>(
replaceContent(BossUi.buildSoftPanel(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"conversations", "devices", "me"}
);
preferredEntrySpinner.setAdapter(adapter);
"偏好设置",
"调整默认首页和提醒行为。",
"保存后会直接写入 /api/v1/settings。"
));
LinearLayout card = BossUi.buildCard(this, "交互偏好", "可切换默认首页与提醒行为。", "保存后立即生效");
card.addView(liveUpdatesSwitch);
card.addView(riskBadgesSwitch);
card.addView(confirmActionsSwitch);
card.addView(preferredEntrySpinner);
appendContent(card);
appendContent(BossUi.buildFormCell(this, "实时刷新", "会话、设备和 OTA 状态变化时自动更新", liveUpdatesSwitch));
appendContent(BossUi.buildFormCell(this, "风险徽标", "在列表中显示风险状态提示", riskBadgesSwitch));
appendContent(BossUi.buildFormCell(this, "危险操作确认", "执行修复或切换前再次确认", confirmActionsSwitch));
appendContent(BossUi.buildFormCell(this, "默认首页", "下次打开 App 优先进入这里", preferredEntrySpinner));
}
private void populate(@Nullable JSONObject settings) {
buildFormContent();
if (settings != null) {
liveUpdatesSwitch.setChecked(settings.optBoolean("liveUpdates", true));
riskBadgesSwitch.setChecked(settings.optBoolean("showRiskBadges", true));
@@ -91,10 +97,16 @@ public class SettingsActivity extends BossScreenActivity {
preferredEntrySpinner.setSelection(0);
}
}
settingsLoaded = settings != null;
updateSaveAvailability();
setRefreshing(false);
}
private void saveSettings() {
if (!settingsLoaded) {
showMessage("设置尚未加载完成,请先刷新成功后再保存。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
@@ -117,4 +129,11 @@ public class SettingsActivity extends BossScreenActivity {
}
});
}
private void updateSaveAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(settingsLoaded);
headerActionButton.setAlpha(settingsLoaded ? 1f : 0.45f);
}
}
}

View File

@@ -45,8 +45,15 @@ public class SkillInventoryActivity extends BossScreenActivity {
}
private String resolveTargetDeviceId() throws Exception {
if (deviceId != null && !deviceId.isEmpty()) {
return deviceId;
String explicitDeviceId = deviceId;
String boundDeviceId = null;
BossApiClient.ApiResponse settingsResponse = apiClient.getSettings();
if (settingsResponse.ok()) {
JSONObject user = settingsResponse.json.optJSONObject("user");
if (user != null) {
String candidate = user.optString("boundDeviceId", "");
boundDeviceId = candidate.isEmpty() ? null : candidate;
}
}
BossApiClient.ApiResponse response = apiClient.getDevices();
if (!response.ok()) throw new IllegalStateException(response.message());
@@ -54,7 +61,54 @@ public class SkillInventoryActivity extends BossScreenActivity {
if (devices == null || devices.length() == 0) {
throw new IllegalStateException("NO_DEVICE");
}
return devices.optJSONObject(0).optString("id");
return chooseTargetDeviceId(explicitDeviceId, boundDeviceId, apiClient.getAccountLabel(), devices);
}
private static String chooseTargetDeviceId(
@Nullable String explicitDeviceId,
@Nullable String boundDeviceId,
String account,
JSONArray devices
) {
String explicitMatch = findDeviceId(devices, explicitDeviceId);
if (explicitMatch != null) {
return explicitMatch;
}
String boundMatch = findDeviceId(devices, boundDeviceId);
if (boundMatch != null) {
return boundMatch;
}
for (int i = 0; i < devices.length(); i++) {
JSONObject device = devices.optJSONObject(i);
if (device == null) continue;
if (account.equals(device.optString("account", ""))) {
return device.optString("id", "");
}
}
if (devices.length() == 1) {
JSONObject onlyDevice = devices.optJSONObject(0);
if (onlyDevice != null) {
return onlyDevice.optString("id", "");
}
}
JSONObject fallback = devices.optJSONObject(0);
return fallback == null ? "" : fallback.optString("id", "");
}
private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) {
if (candidateDeviceId == null || candidateDeviceId.isEmpty()) {
return null;
}
for (int i = 0; i < devices.length(); i++) {
JSONObject device = devices.optJSONObject(i);
if (device == null) continue;
if (candidateDeviceId.equals(device.optString("id", ""))) {
return candidateDeviceId;
}
}
return null;
}
private void renderSkills(JSONObject payload) {
@@ -65,7 +119,7 @@ public class SkillInventoryActivity extends BossScreenActivity {
if (device != null) {
deviceName = device.optString("name", deviceId);
configureScreen("技能", deviceName);
appendContent(BossUi.buildCard(
appendContent(BossUi.buildSoftPanel(
this,
deviceName,
"当前页按设备查看 Skill 清单。",
@@ -81,19 +135,22 @@ public class SkillInventoryActivity extends BossScreenActivity {
for (int i = 0; i < skills.length(); i++) {
JSONObject skill = skills.optJSONObject(i);
if (skill == null) continue;
LinearLayout card = BossUi.buildCard(
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
skill.optString("name", "未命名 Skill"),
skill.optString("description", "未提供说明"),
skill.optString("category", "-")
+ " · " + skill.optString("updatedAt", "-")
);
Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句");
+ " · " + skill.optString("updatedAt", "-"),
null,
null
));
Button copyInvocation = BossUi.buildMiniActionButton(this, "复制调用", true);
copyInvocation.setOnClickListener(v -> BossUi.copyText(this, "Skill 调用", skill.optString("invocation", "")));
card.addView(copyInvocation);
Button copyPath = BossUi.buildSecondaryButton(this, "复制路径");
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
card.addView(copyPath);
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
appendContent(card);
}
setRefreshing(false);

View File

@@ -0,0 +1,402 @@
package com.hyzq.boss;
import org.json.JSONObject;
import org.json.JSONArray;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class WechatSurfaceMapper {
private static final List<String> ROOT_TAB_LABELS = Arrays.asList(
"会话",
"设备",
"我的"
);
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"),
new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"),
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
);
private static final List<String> PROJECT_QUICK_ACTIONS = Arrays.asList(
"项目目标",
"版本记录"
);
private static final List<String> PROJECT_PRIMARY_SECTIONS = Arrays.asList(
"quick_actions",
"messages",
"composer"
);
private WechatSurfaceMapper() {
}
public static ConversationRow toConversationRow(JSONObject item) {
JSONObject source = item == null ? new JSONObject() : item;
JSONArray members = source.optJSONArray("groupMembers");
List<GroupAvatarMember> groupAvatarMembers = new ArrayList<>();
if (members != null) {
for (int i = 0; i < members.length(); i++) {
JSONObject member = members.optJSONObject(i);
if (member == null) continue;
groupAvatarMembers.add(new GroupAvatarMember(
member.optString("threadId", ""),
member.optString("avatar", ""),
member.optString("title", "")
));
}
}
JSONObject avatar = source.optJSONObject("avatar");
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
return new ConversationRow(
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
source.optString("folderLabel", ""),
source.optString("lastMessagePreview", source.optString("preview", "")),
source.optString("timeLabel", source.optString("latestReplyLabel", "")),
source.optInt("unreadCount", 0),
source.optString("topPinnedLabel", ""),
source.optInt("activityIconCount", 0),
isGroup,
isGroup ? "" : avatar == null ? "" : avatar.optString("primary", ""),
isGroup ? "" : avatar == null ? "" : avatar.optString("secondary", ""),
groupAvatarMembers.toArray(new GroupAvatarMember[0])
);
}
public static DeviceRow toDeviceRow(JSONObject item) {
JSONObject source = item == null ? new JSONObject() : item;
return new DeviceRow(
source.optString("title", source.optString("name", "")),
buildDeviceAccountProjectLine(source),
buildDeviceQuotaStatusLine(source),
source.optString("avatar", ""),
resolveDeviceStatusKey(source)
);
}
public static DeviceDetailSummary toDeviceDetailSummary(JSONObject item) {
JSONObject source = item == null ? new JSONObject() : item;
DeviceRow row = toDeviceRow(source);
return new DeviceDetailSummary(
row.title,
row.subtitle,
mergeDeviceMeta(row.meta, buildDetailMeta(source))
);
}
public static String[] rootTabLabels() {
return ROOT_TAB_LABELS.toArray(new String[0]);
}
public static String[] rootMeMenuTitles() {
String[] titles = new String[ROOT_ME_MENU_ITEMS.size()];
for (int i = 0; i < ROOT_ME_MENU_ITEMS.size(); i++) {
titles[i] = ROOT_ME_MENU_ITEMS.get(i).title;
}
return titles;
}
public static MeMenuItem[] rootMeMenuItems() {
return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]);
}
public static MeMenuItem findMeMenuItem(String key) {
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
if (item.key.equals(key)) {
return item;
}
}
return null;
}
public static String[] projectQuickActions() {
return PROJECT_QUICK_ACTIONS.toArray(new String[0]);
}
public static Class<? extends BossScreenActivity> resolveConversationInfoTargetClass(boolean isGroup) {
return isGroup ? GroupInfoActivity.class : ConversationInfoActivity.class;
}
public static String[] projectPrimarySections() {
return PROJECT_PRIMARY_SECTIONS.toArray(new String[0]);
}
public static String conversationInfoActionLabel() {
return "信息";
}
public static String loginTitle() {
return "会话";
}
public static String loginHintText() {
return "轻量会话首页已恢复,直接进入最近线程。";
}
public static String loginButtonLabel() {
return "进入会话";
}
public static String conversationsHintPillText() {
return "项目自动对应设备 GUI 项目文件夹";
}
public static String conversationsHeaderSubtitle() {
return "";
}
public static String conversationActivityIconMode() {
return "animated_dots";
}
public static int maxConversationActivityIcons() {
return 4;
}
public static String conversationActivityAnimationCleanup() {
return "cancel_on_detach";
}
public static <T> T resolveRefreshValue(T cachedValue, T freshValue, boolean requestSucceeded) {
if (requestSucceeded) {
return freshValue;
}
return cachedValue;
}
private static String buildSubtitle(JSONObject source) {
String status = localizeDeviceStatus(resolveDeviceStatusKey(source));
String account = source.optString("account", "");
if (account.isEmpty()) {
return status;
}
return status + " · " + account;
}
private static String buildDeviceAccountProjectLine(JSONObject source) {
StringBuilder builder = new StringBuilder();
String account = source.optString("account", "");
if (!account.isEmpty()) {
builder.append("账号: ").append(account);
}
String projects = joinProjects(source.optJSONArray("projects"));
if (!projects.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append("项目: ").append(projects);
}
if (builder.length() == 0) {
return buildSubtitle(source);
}
return builder.toString();
}
private static String buildDeviceQuotaStatusLine(JSONObject source) {
StringBuilder builder = new StringBuilder("额度: ");
int quota5h = source.optInt("quota5h", -1);
int quota7d = source.optInt("quota7d", -1);
boolean hasQuota = false;
if (quota5h >= 0) {
builder.append("5h ").append(quota5h).append("%");
hasQuota = true;
}
if (quota7d >= 0) {
if (hasQuota) {
builder.append(" · ");
}
builder.append("7d ").append(quota7d).append("%");
hasQuota = true;
}
if (!hasQuota) {
builder.append("暂无");
}
String statusKey = resolveDeviceStatusKey(source);
if ("abnormal".equals(statusKey)) {
builder.append(" · 状态异常");
} else if ("offline".equals(statusKey)) {
builder.append(" · 当前离线");
}
return builder.toString();
}
private static String mergeDeviceMeta(String primary, String secondary) {
if (primary == null || primary.isEmpty()) {
return secondary;
}
if (secondary == null || secondary.isEmpty()) {
return primary;
}
return primary + " · " + secondary;
}
private static String joinProjects(JSONArray projects) {
if (projects == null || projects.length() == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < projects.length(); i++) {
String project = projects.optString(i);
if (project == null || project.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append(" / ");
}
builder.append(project);
}
return builder.toString();
}
private static String resolveDeviceStatusKey(JSONObject source) {
String statusValue = source.optString("status", "");
if (source.optBoolean("online", false) || "online".equals(statusValue)) {
return "online";
}
if ("abnormal".equals(statusValue)) {
return "abnormal";
}
return "offline";
}
private static String localizeDeviceStatus(String statusKey) {
if ("online".equals(statusKey)) {
return "在线";
}
if ("abnormal".equals(statusKey)) {
return "异常";
}
return "离线";
}
private static String buildDetailMeta(JSONObject source) {
StringBuilder builder = new StringBuilder();
appendSegment(builder, source.optString("note", ""));
appendSegment(builder, source.optString("endpoint", ""));
JSONArray projects = source.optJSONArray("projects");
if (projects != null && projects.length() > 0) {
StringBuilder projectBuilder = new StringBuilder();
for (int i = 0; i < projects.length(); i++) {
String project = projects.optString(i);
if (project.isEmpty()) continue;
if (projectBuilder.length() > 0) {
projectBuilder.append(", ");
}
projectBuilder.append(project);
}
if (projectBuilder.length() > 0) {
appendSegment(builder, "项目 " + projectBuilder);
}
}
return builder.length() == 0 ? null : builder.toString();
}
private static void appendSegment(StringBuilder builder, String value) {
if (value == null || value.isEmpty()) {
return;
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(value);
}
public static final class ConversationRow {
public final String threadTitle;
public final String folderLabel;
public final String lastMessagePreview;
public final String timeLabel;
public final int unreadCount;
public final String topPinnedLabel;
public final int activityIconCount;
public final boolean isGroup;
public final String avatarPrimary;
public final String avatarSecondary;
public final GroupAvatarMember[] groupAvatarMembers;
public ConversationRow(
String threadTitle,
String folderLabel,
String lastMessagePreview,
String timeLabel,
int unreadCount,
String topPinnedLabel,
int activityIconCount,
boolean isGroup,
String avatarPrimary,
String avatarSecondary,
GroupAvatarMember[] groupAvatarMembers
) {
this.threadTitle = threadTitle;
this.folderLabel = folderLabel;
this.lastMessagePreview = lastMessagePreview;
this.timeLabel = timeLabel;
this.unreadCount = unreadCount;
this.topPinnedLabel = topPinnedLabel;
this.activityIconCount = activityIconCount;
this.isGroup = isGroup;
this.avatarPrimary = avatarPrimary;
this.avatarSecondary = avatarSecondary;
this.groupAvatarMembers = groupAvatarMembers == null ? new GroupAvatarMember[0] : groupAvatarMembers;
}
}
public static final class GroupAvatarMember {
public final String threadId;
public final String avatarLabel;
public final String title;
public GroupAvatarMember(String threadId, String avatarLabel, String title) {
this.threadId = threadId;
this.avatarLabel = avatarLabel;
this.title = title;
}
}
public static final class DeviceRow {
public final String title;
public final String subtitle;
public final String meta;
public final String avatarLabel;
public final String statusKey;
public DeviceRow(String title, String subtitle, String meta, String avatarLabel, String statusKey) {
this.title = title;
this.subtitle = subtitle;
this.meta = meta;
this.avatarLabel = avatarLabel;
this.statusKey = statusKey;
}
}
public static final class DeviceDetailSummary {
public final String title;
public final String subtitle;
public final String meta;
public DeviceDetailSummary(String title, String subtitle, String meta) {
this.title = title;
this.subtitle = subtitle;
this.meta = meta;
}
}
public static final class MeMenuItem {
public final String key;
public final String title;
public final String description;
public MeMenuItem(String key, String title, String description) {
this.key = key;
this.title = title;
this.description = description;
}
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/boss_bg_app" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/boss_surface" />
<stroke
android:width="1dp"
android:color="@color/boss_divider" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/boss_surface" />
<corners
android:topLeftRadius="8dp"
android:topRightRadius="18dp"
android:bottomLeftRadius="18dp"
android:bottomRightRadius="18dp" />
<stroke
android:width="1dp"
android:color="@color/boss_card_stroke" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/boss_green" />
<corners
android:topLeftRadius="18dp"
android:topRightRadius="8dp"
android:bottomLeftRadius="18dp"
android:bottomRightRadius="18dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1407C160" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/boss_surface" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="14dp">
<Button
android:id="@+id/screen_back_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="返回"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="会话信息"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="单线程会话信息页"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<Button
android:id="@+id/screen_header_action"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="操作"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold"
android:visibility="gone" />
<Button
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="14dp">
<Button
android:id="@+id/screen_back_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="返回"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="标题"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="副标题"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<Button
android:id="@+id/screen_header_action"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="操作"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold"
android:visibility="gone" />
<Button
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="14dp">
<Button
android:id="@+id/screen_back_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="返回"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="发起群聊"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="从当前会话选择其他线程"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<Button
android:id="@+id/screen_header_action"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="操作"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold"
android:visibility="gone" />
<Button
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="14dp">
<Button
android:id="@+id/screen_back_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="返回"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="群资料"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="群聊资料页"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<Button
android:id="@+id/screen_header_action"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="操作"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold"
android:visibility="gone" />
<Button
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -2,7 +2,7 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_app_gradient">
android:background="@color/boss_bg_app">
<ScrollView
android:id="@+id/login_panel"
@@ -18,23 +18,24 @@
android:paddingLeft="24dp"
android:paddingTop="72dp"
android:paddingRight="24dp"
android:paddingBottom="32dp">
android:paddingBottom="40dp">
<TextView
android:layout_width="72dp"
android:layout_height="72dp"
android:background="@drawable/bg_primary_button"
android:background="@drawable/bg_secondary_button"
android:gravity="center"
android:text="B"
android:textColor="@color/boss_surface"
android:textSize="30sp"
android:textColor="@color/boss_green"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:id="@+id/login_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Boss 原生控制台"
android:layout_marginTop="22dp"
android:text=""
android:textColor="@color/boss_text_primary"
android:textSize="30sp"
android:textStyle="bold" />
@@ -43,38 +44,12 @@
android:id="@+id/login_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginTop="12dp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:text="原生 Android 客户端已启用。点击下方按钮直接进入系统。"
android:text=""
android:textColor="@color/boss_text_muted"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:background="@drawable/bg_card"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="当前临时模式"
android:textColor="@color/boss_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:lineSpacingExtra="4dp"
android:text="1. 这是原生 Android Activity不再打开 WebView。\n2. 登录暂时不做验证,点击按钮会直接进入最高管理员会话。\n3. 会话 / 设备 / 我的三栏都直接调用现有 Boss API。"
android:textColor="@color/boss_text_primary"
android:textSize="14sp" />
</LinearLayout>
android:textSize="14sp" />
<ProgressBar
android:id="@+id/login_progress"
@@ -87,11 +62,11 @@
android:id="@+id/login_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginTop="22dp"
android:background="@drawable/bg_primary_button"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="登录"
android:text=""
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textSize="18sp"
@@ -109,12 +84,13 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="18dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="16dp">
android:paddingBottom="12dp">
<Button
android:id="@+id/back_button"
@@ -144,7 +120,7 @@
android:layout_height="wrap_content"
android:text="会话"
android:textColor="@color/boss_text_primary"
android:textSize="24sp"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
@@ -152,9 +128,10 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="原生 Android 客户端,直接消费 Boss API。"
android:text=""
android:textColor="@color/boss_text_muted"
android:textSize="13sp" />
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
<Button
@@ -162,11 +139,12 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_secondary_button"
android:paddingLeft="16dp"
android:paddingTop="10dp"
android:paddingRight="16dp"
android:paddingBottom="10dp"
android:text="刷新"
android:minWidth="0dp"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp"
android:text="刷"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
@@ -192,10 +170,11 @@
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingLeft="20dp"
android:paddingTop="8dp"
android:paddingRight="20dp"
android:paddingTop="12dp"
android:paddingLeft="0dp"
android:paddingRight="0dp"
android:paddingBottom="88dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
@@ -203,11 +182,13 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="76dp"
android:layout_height="72dp"
android:background="@color/boss_surface"
android:elevation="10dp"
android:gravity="center"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingLeft="12dp"
android:paddingRight="12dp">
@@ -217,10 +198,10 @@
android:layout_height="48dp"
android:layout_marginRight="6dp"
android:layout_weight="1"
android:background="@drawable/bg_primary_button"
android:background="@drawable/bg_tab_active"
android:text="会话"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<Button
@@ -230,10 +211,10 @@
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:background="@drawable/bg_tab_inactive"
android:text="设备"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textColor="@color/boss_text_muted"
android:textStyle="bold" />
<Button
@@ -242,10 +223,10 @@
android:layout_height="48dp"
android:layout_marginLeft="6dp"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:background="@drawable/bg_tab_inactive"
android:text="我的"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textColor="@color/boss_text_muted"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="14dp"
android:paddingRight="16dp"
android:paddingBottom="12dp">
<Button
android:id="@+id/screen_back_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="返回"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="项目详情"
android:textColor="@color/boss_text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="设备"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<Button
android:id="@+id/screen_header_action"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="操作"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold"
android:visibility="gone" />
<Button
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:id="@+id/project_chat_scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="20dp">
<LinearLayout
android:id="@+id/project_chat_quick_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:orientation="horizontal" />
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/project_chat_composer_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="bottom"
android:orientation="horizontal"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="12dp">
<EditText
android:id="@+id/project_chat_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:gravity="top|start"
android:hint="输入消息"
android:inputType="textCapSentences|textMultiLine"
android:maxLines="4"
android:minHeight="44dp"
android:paddingLeft="14dp"
android:paddingTop="10dp"
android:paddingRight="14dp"
android:paddingBottom="10dp"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted" />
<Button
android:id="@+id/project_chat_send"
android:layout_width="72dp"
android:layout_height="44dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_primary_button"
android:text="发送"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/project_chat_multi_select_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="12dp"
android:visibility="gone">
<Button
android:id="@+id/project_chat_multi_forward"
android:layout_width="match_parent"
android:layout_height="44dp"
android:background="@drawable/bg_primary_button"
android:text="转发"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View File

@@ -2,12 +2,13 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_app_gradient"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="16dp"
@@ -74,13 +75,13 @@
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/bg_primary_button"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textColor="@color/boss_green"
android:textStyle="bold" />
</LinearLayout>
@@ -99,10 +100,9 @@
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingLeft="18dp"
android:paddingTop="6dp"
android:paddingRight="18dp"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -3,11 +3,15 @@
<color name="boss_green">#07C160</color>
<color name="boss_green_dark">#04984B</color>
<color name="boss_surface">#FFFFFFFF</color>
<color name="boss_bg_start">#FFF1F6EE</color>
<color name="boss_bg_end">#FFE3F0E3</color>
<color name="boss_card_stroke">#1A0F1B12</color>
<color name="boss_bg_start">#FFF7F7F7</color>
<color name="boss_bg_end">#FFF7F7F7</color>
<color name="boss_bg_app">#FFF7F7F7</color>
<color name="boss_panel">#FFFFFFFF</color>
<color name="boss_card_stroke">#14000000</color>
<color name="boss_divider">#FFEAEAEA</color>
<color name="boss_text_primary">#FF111111</color>
<color name="boss_text_muted">#FF5F6B63</color>
<color name="boss_text_soft">#FF8E8E93</color>
<color name="colorPrimary">@color/boss_green</color>
<color name="colorPrimaryDark">@color/boss_green_dark</color>
<color name="colorAccent">@color/boss_green</color>

View File

@@ -6,17 +6,17 @@
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
</style>
</resources>

View File

@@ -0,0 +1,65 @@
package com.hyzq.boss;
import static org.junit.Assert.assertArrayEquals;
import org.json.JSONObject;
import org.junit.Test;
public class AboutActivityStaleDownloadCleanupTest {
@Test
public void collectStaleDownloadIdsForRemoval_returnsIdsWhenReleaseChanged() throws Exception {
JSONObject availableRelease = new StubJSONObject()
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
.withString("version", "v1.2.9");
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
availableRelease,
"boss-android-v1.2.8-release.apk",
"v1.2.8",
true,
42L,
77L
);
assertArrayEquals(new long[]{42L, 77L}, ids);
}
@Test
public void collectStaleDownloadIdsForRemoval_returnsEmptyWhenReleaseMatchesLocalPackage() throws Exception {
JSONObject availableRelease = new StubJSONObject()
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
.withString("version", "v1.2.9");
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
availableRelease,
"boss-android-v1.2.9-release.apk",
"v1.2.9",
true,
42L,
77L
);
assertArrayEquals(new long[0], ids);
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
String value = optString(key);
return value.isEmpty() ? fallback : value;
}
}
}

View File

@@ -0,0 +1,227 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.SharedPreferences;
import org.json.JSONObject;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class BossApiClientForwardingTest {
@Test
public void forwardProjectMessageWritesStructuredJsonBody() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/source/forwards"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
JSONObject payload = ForwardPayloads.build("single", "m1", java.util.List.of());
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage("source", "target", payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/source/forwards", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
connection.requestBody()
);
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测只关心 request body不需要走 Android org.json 的身份恢复副作用。
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -0,0 +1,109 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import java.util.List;
public class ForwardTargetActivityTest {
@Test
public void filtersOutSourceConversationFromTargets() {
JSONArray conversations = new StubJSONArray(
new StubJSONObject().withString("projectId", "source").withString("projectTitle", "源会话"),
new StubJSONObject().withString("projectId", "target").withString("projectTitle", "目标会话")
);
List<JSONObject> result = ForwardTargetActivity.collectSelectableTargets(conversations, "source");
assertEquals(1, result.size());
assertEquals("target", result.get(0).optString("projectId"));
}
@Test
public void singleModeRequiresOneMessageId() throws Exception {
JSONObject payload = ForwardTargetActivity.buildForwardPayload("single", "m1", java.util.List.of());
assertEquals("single", payload.optString("mode"));
assertEquals("m1", payload.optString("sourceMessageId"));
assertEquals(
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
ForwardPayloads.toRequestBody("target", payload)
);
}
@Test
public void bundleModeUsesOrderedMessageIds() throws Exception {
JSONObject payload = ForwardTargetActivity.buildForwardPayload("bundle", null, java.util.List.of("m1", "m2"));
assertEquals("bundle", payload.optString("mode"));
assertEquals(2, payload.optJSONArray("sourceMessageIds").length());
assertEquals("m1", payload.optJSONArray("sourceMessageIds").optString(0));
assertEquals("m2", payload.optJSONArray("sourceMessageIds").optString(1));
assertEquals(
"{\"targetProjectId\":\"target\",\"mode\":\"bundle\",\"sourceMessageIds\":[\"m1\",\"m2\"]}",
ForwardPayloads.toRequestBody("target", payload)
);
}
@Test
public void approvalRequiredResponseUsesApprovalMessage() {
StubJSONObject response = new StubJSONObject().withBoolean("approvalRequired", true);
assertEquals("已提交主 Agent 审批", ForwardTargetActivity.resolveForwardResultMessage(response));
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
StubJSONObject withBoolean(String key, boolean value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
Object value = values.get(key);
return value instanceof String ? (String) value : fallback;
}
@Override
public boolean optBoolean(String key, boolean fallback) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : fallback;
}
}
private static final class StubJSONArray extends JSONArray {
private final JSONObject[] values;
StubJSONArray(JSONObject... values) {
this.values = values == null ? new JSONObject[0] : values;
}
@Override
public int length() {
return values.length;
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= values.length) {
return null;
}
return values[index];
}
}
}

View File

@@ -0,0 +1,131 @@
package com.hyzq.boss;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import java.util.LinkedHashSet;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class GroupCreateActivityTest {
@Test
public void collectSelectableConversationItems_filtersOutExistingGroupChats() {
JSONObject threadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "线程一")
.withBoolean("isGroup", false);
JSONObject groupConversation = new StubJSONObject()
.withString("projectId", "group-1")
.withString("projectTitle", "已有群聊")
.withBoolean("isGroup", true);
JSONObject sourceConversation = new StubJSONObject()
.withString("projectId", "source-1")
.withString("projectTitle", "来源线程")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", threadConversation, groupConversation, sourceConversation);
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, "source-1");
assertEquals(1, filtered.size());
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
}
@Test
public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
Set<String> previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
Set<String> currentSelectedIds = linkedSet("thread-1", "thread-3");
Set<String> nextCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
Set<String> reconciled = GroupCreateActivity.reconcileSelectedProjectIds(
currentSelectedIds,
previousCandidateIds,
nextCandidateIds
);
assertEquals(2, reconciled.size());
assertTrue(reconciled.contains("thread-1"));
assertTrue(reconciled.contains("thread-3"));
assertFalse(reconciled.contains("thread-2"));
}
@Test
public void canCreateGroupChat_blocksWhileRefreshingOrCreating() {
Set<String> selectedProjectIds = linkedSet("thread-1");
assertFalse(GroupCreateActivity.canCreateGroupChat(true, false, selectedProjectIds));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, true, selectedProjectIds));
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, selectedProjectIds));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet()));
}
private static Set<String> linkedSet(String... values) {
Set<String> result = new LinkedHashSet<>();
for (String value : values) {
result.add(value);
}
return result;
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
StubJSONObject withBoolean(String key, boolean value) {
values.put(key, value);
return this;
}
StubJSONObject withObjectArray(String key, JSONObject... entries) {
values.put(key, new StubJSONArray(entries));
return this;
}
@Override
public String optString(String key, String defaultValue) {
Object value = values.get(key);
return value instanceof String ? (String) value : defaultValue;
}
@Override
public boolean optBoolean(String key, boolean defaultValue) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : defaultValue;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
}
private static final class StubJSONArray extends JSONArray {
private final JSONObject[] entries;
StubJSONArray(JSONObject... entries) {
this.entries = entries == null ? new JSONObject[0] : entries;
}
@Override
public int length() {
return entries.length;
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= entries.length) {
return null;
}
return entries[index];
}
}
}

View File

@@ -0,0 +1,17 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class OtaDownloadStateMapperTest {
@Test
public void toProgressLabel_formatsKnownProgress() {
assertEquals("已下载 50%", OtaDownloadStateMapper.toProgressLabel(50, true));
}
@Test
public void toProgressLabel_handlesUnknownProgress() {
assertEquals("正在准备下载", OtaDownloadStateMapper.toProgressLabel(0, false));
}
}

View File

@@ -0,0 +1,145 @@
package com.hyzq.boss;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
public class ProjectChatUiStateTest {
@Test
public void sendEnabled_requiresTextAndNotBusy() {
assertFalse(ProjectChatUiState.canSend("", false));
assertFalse(ProjectChatUiState.canSend(" ", false));
assertFalse(ProjectChatUiState.canSend("你好", true));
assertTrue(ProjectChatUiState.canSend("你好", false));
}
@Test
public void shouldAutoScroll_onlyWhenNearBottomOrForced() {
assertTrue(ProjectChatUiState.shouldAutoScroll(true, false));
assertTrue(ProjectChatUiState.shouldAutoScroll(false, true));
assertFalse(ProjectChatUiState.shouldAutoScroll(false, false));
}
@Test
public void entersMultiSelectModeAfterFirstToggle() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
assertTrue(state.multiSelecting);
assertEquals(1, state.selectedMessageIds.size());
assertTrue(state.selectedMessageIds.contains("m1"));
}
@Test
public void deselectingLastMessageExitsMultiSelectMode() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m1");
assertFalse(next.multiSelecting);
assertTrue(next.selectedMessageIds.isEmpty());
}
@Test
public void bundleForwardRequiresAtLeastTwoMessages() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
assertFalse(ProjectChatUiState.canForwardSelection(state));
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m2");
assertTrue(ProjectChatUiState.canForwardSelection(next));
}
@Test
public void selectionPreservesInsertionOrder() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
state = ProjectChatUiState.toggleSelection(state, "m1");
state = ProjectChatUiState.toggleSelection(state, "m3");
assertArrayEquals(
new String[] {"m2", "m1", "m3"},
new ArrayList<>(state.selectedMessageIds).toArray(new String[0])
);
}
@Test
public void toggleSelectionRejectsBlankMessageIds() {
try {
ProjectChatUiState.toggleSelection(null, " ");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException expected) {
assertEquals("messageId must not be blank", expected.getMessage());
}
}
@Test
public void singleForwardMessageUsesSingleModeLabel() {
assertEquals("转发", ProjectChatUiState.labelForForwardKind("forward_single"));
}
@Test
public void bundleForwardMessageUsesBundleModeLabel() {
assertEquals("聊天记录", ProjectChatUiState.labelForForwardKind("forward_bundle"));
}
@Test
public void chromeStateUsesMultiSelectHeaderAndActionsWhenSelecting() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
state = ProjectChatUiState.toggleSelection(state, "m2");
ProjectChatUiState.ChromeState chromeState =
ProjectChatUiState.resolveChromeState(state, true, "北区试产线回归", "归档确认");
assertTrue(chromeState.multiSelecting);
assertFalse(chromeState.showComposer);
assertTrue(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction);
assertTrue(chromeState.forwardEnabled);
assertEquals("取消", chromeState.backLabel);
assertEquals("已选 2 条", chromeState.title);
assertEquals("选择要转发的消息", chromeState.subtitle);
}
@Test
public void chromeStateUsesConversationHeaderWhenNotSelecting() {
ProjectChatUiState.ChromeState chromeState =
ProjectChatUiState.resolveChromeState(ProjectChatUiState.emptySelection(), true, "北区试产线回归", "归档确认");
assertFalse(chromeState.multiSelecting);
assertTrue(chromeState.showComposer);
assertFalse(chromeState.showMultiSelectBar);
assertTrue(chromeState.showRefresh);
assertTrue(chromeState.showHeaderAction);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title);
assertEquals("归档确认", chromeState.subtitle);
}
@Test
public void reconcileSelectionDropsMessagesMissingFromRenderSet() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
state = ProjectChatUiState.toggleSelection(state, "m2");
ProjectChatUiState.SelectionState reconciled =
ProjectChatUiState.reconcileSelection(state, List.of("m2", "m3"));
assertTrue(reconciled.multiSelecting);
assertEquals(List.of("m2"), new ArrayList<>(reconciled.selectedMessageIds));
}
@Test
public void summarizeForwardBundleTruncatesLongLastMessage() {
String summary = ProjectChatUiState.summarizeForwardBundle(
"这是一条很长很长很长的转发消息摘要,用来验证截断逻辑是否生效并避免卡片过高",
3
);
assertTrue(summary.startsWith("3 条消息 · 最后一条:"));
assertTrue(summary.endsWith(""));
assertTrue(summary.contains("这是一条很长很长很长的转发消息摘要"));
}
}

View File

@@ -0,0 +1,57 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class ProjectDetailActivityChromeBindingsTest {
@Test
public void multiSelectBindingsHideComposerAndDisableRefresh() {
ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.selectOnly("m1");
selectionState = ProjectChatUiState.toggleSelection(selectionState, "m2");
ProjectChatUiState.ChromeState chromeState = ProjectChatUiState.resolveChromeState(
selectionState,
true,
"北区试产线回归",
"归档确认"
);
ProjectDetailActivity.ChromeBindings bindings =
ProjectDetailActivity.buildChromeBindings(chromeState, false);
assertTrue(bindings.multiSelecting);
assertFalse(bindings.showComposer);
assertTrue(bindings.showMultiSelectBar);
assertFalse(bindings.showRefresh);
assertFalse(bindings.showHeaderAction);
assertTrue(bindings.enableForwardButton);
assertFalse(bindings.enablePullRefresh);
assertEquals("取消", bindings.backLabel);
}
@Test
public void normalBindingsRestoreConversationChrome() {
ProjectChatUiState.ChromeState chromeState = ProjectChatUiState.resolveChromeState(
ProjectChatUiState.emptySelection(),
true,
"北区试产线回归",
"归档确认"
);
ProjectDetailActivity.ChromeBindings bindings =
ProjectDetailActivity.buildChromeBindings(chromeState, true);
assertFalse(bindings.multiSelecting);
assertTrue(bindings.showComposer);
assertFalse(bindings.showMultiSelectBar);
assertTrue(bindings.showRefresh);
assertTrue(bindings.showHeaderAction);
assertFalse(bindings.enableForwardButton);
assertTrue(bindings.enablePullRefresh);
assertEquals("返回", bindings.backLabel);
assertEquals("北区试产线回归", bindings.title);
assertEquals("归档确认", bindings.subtitle);
}
}

View File

@@ -0,0 +1,98 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityUiTest {
@Test
public void multiSelectModeUpdatesRealChatChrome() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "北区试产线回归");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
LinearLayout contentLayout = activity.findViewById(R.id.screen_content);
View firstMessage = buildBoundMessageView(activity, "m1", "第一条消息");
View secondMessage = buildBoundMessageView(activity, "m2", "第二条消息");
contentLayout.addView(firstMessage);
contentLayout.addView(secondMessage);
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
firstMessage.performLongClick();
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
View multiSelectItem = listView.getAdapter().getView(1, null, listView);
listView.performItemClick(multiSelectItem, 1, listView.getAdapter().getItemId(1));
LinearLayout composerRow = activity.findViewById(R.id.project_chat_composer_row);
LinearLayout multiSelectActions = activity.findViewById(R.id.project_chat_multi_select_actions);
Button backButton = activity.findViewById(R.id.screen_back_button);
Button refreshButton = activity.findViewById(R.id.screen_refresh_button);
Button forwardButton = activity.findViewById(R.id.project_chat_multi_forward);
assertEquals(View.GONE, composerRow.getVisibility());
assertEquals(View.VISIBLE, multiSelectActions.getVisibility());
assertEquals("取消", backButton.getText().toString());
assertEquals(View.GONE, refreshButton.getVisibility());
assertEquals(false, forwardButton.isEnabled());
secondMessage.performClick();
assertTrue(forwardButton.isEnabled());
backButton.performClick();
assertEquals(View.VISIBLE, composerRow.getVisibility());
assertEquals(View.GONE, multiSelectActions.getVisibility());
assertEquals("返回", backButton.getText().toString());
assertEquals(View.VISIBLE, refreshButton.getVisibility());
}
private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) {
TextView messageView = new TextView(activity);
messageView.setText(body);
messageView.setGravity(Gravity.START);
ReflectionHelpers.callInstanceMethod(
activity,
"bindMessageInteractions",
ReflectionHelpers.ClassParameter.from(View.class, messageView),
ReflectionHelpers.ClassParameter.from(String.class, messageId),
ReflectionHelpers.ClassParameter.from(String.class, body)
);
return messageView;
}
public static class TestProjectDetailActivity extends ProjectDetailActivity {
@Override
boolean shouldLoadOnCreate() {
return false;
}
}
}

View File

@@ -0,0 +1,27 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class RootTabMemoryTest {
@Test
public void resolveInitialTab_prefersExplicitTab() {
assertEquals("devices", RootTabMemory.resolveInitialTab("devices", "me", "conversations"));
}
@Test
public void resolveInitialTab_fallsBackToStoredTab() {
assertEquals("me", RootTabMemory.resolveInitialTab(null, "me", "devices"));
}
@Test
public void resolveInitialTab_usesPreferredEntryBeforeDefault() {
assertEquals("devices", RootTabMemory.resolveInitialTab(null, null, "devices"));
}
@Test
public void resolveInitialTab_defaultsToConversations() {
assertEquals("conversations", RootTabMemory.resolveInitialTab(null, null, null));
}
}

View File

@@ -0,0 +1,450 @@
package com.hyzq.boss;
import org.junit.Test;
import org.json.JSONArray;
import org.json.JSONObject;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class WechatSurfaceMapperTest {
@Test
public void toConversationRow_mapsWechatConversationFieldsFromThreadPayload() throws Exception {
JSONObject item = new StubJSONObject()
.withString("projectTitle", "项目 A")
.withString("threadTitle", "北区试产线回归")
.withString("folderLabel", "归档确认")
.withString("preview", "旧预览")
.withString("lastMessagePreview", "现场摄像头关键帧")
.withString("latestReplyLabel", "09:26")
.withInt("unreadCount", 3)
.withInt("activityIconCount", 2)
.withString("topPinnedLabel", "置顶")
.withBoolean("isGroup", false)
.withObject("avatar", new StubJSONObject()
.withString("primary", "M")
.withString("secondary", "W"))
.withObjectArray("groupMembers",
new StubJSONObject().withString("threadId", "t-1").withString("avatar", "M").withString("title", "Mac Studio"),
new StubJSONObject().withString("threadId", "t-2").withString("avatar", "W").withString("title", "Windows GPU"));
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("北区试产线回归", row.threadTitle);
assertEquals("归档确认", row.folderLabel);
assertEquals("现场摄像头关键帧", row.lastMessagePreview);
assertEquals("09:26", row.timeLabel);
assertEquals(3, row.unreadCount);
assertEquals("置顶", row.topPinnedLabel);
assertEquals(2, row.activityIconCount);
assertFalse(row.isGroup);
assertEquals("M", row.avatarPrimary);
assertEquals("W", row.avatarSecondary);
assertEquals(2, row.groupAvatarMembers.length);
assertEquals("Mac Studio", row.groupAvatarMembers[0].title);
assertEquals("W", row.groupAvatarMembers[1].avatarLabel);
}
@Test
public void toConversationRow_prefersGroupMembersForGroupAvatarSummary() throws Exception {
JSONObject item = new StubJSONObject()
.withString("projectTitle", "群聊项目")
.withString("threadTitle", "容灾切换验证")
.withString("folderLabel", "Mac + Windows 协作")
.withString("lastMessagePreview", "最新: API 切换记录回传")
.withString("latestReplyLabel", "09:12")
.withInt("activityIconCount", 2)
.withString("topPinnedLabel", "置顶")
.withBoolean("isGroup", true)
.withObjectArray("groupMembers",
new StubJSONObject().withString("threadId", "group-1").withString("avatar", "M").withString("title", "Mac Studio"),
new StubJSONObject().withString("threadId", "group-2").withString("avatar", "W").withString("title", "Windows GPU"),
new StubJSONObject().withString("threadId", "group-3").withString("avatar", "C").withString("title", "Cloud Backup"));
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertTrue(row.isGroup);
assertEquals("容灾切换验证", row.threadTitle);
assertEquals("Mac + Windows 协作", row.folderLabel);
assertEquals("最新: API 切换记录回传", row.lastMessagePreview);
assertEquals("09:12", row.timeLabel);
assertEquals(3, row.groupAvatarMembers.length);
assertEquals("M", row.groupAvatarMembers[0].avatarLabel);
assertEquals("Cloud Backup", row.groupAvatarMembers[2].title);
assertEquals("", row.avatarPrimary);
}
@Test
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("avatar", "M")
.withString("status", "online")
.withString("account", "17600003315")
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
.withInt("quota5h", 8)
.withInt("quota7d", 22);
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
assertEquals("M", row.avatarLabel);
assertEquals("online", row.statusKey);
}
@Test
public void toDeviceRow_preservesAbnormalStatus() throws Exception {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("status", "abnormal")
.withString("account", "17600003315");
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315", row.subtitle);
assertEquals("额度: 暂无 · 状态异常", row.meta);
assertEquals("abnormal", row.statusKey);
}
@Test
public void deviceDetailSummary_keepsEssentialContextAlongsideNoteEndpointAndProjects() throws Exception {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("status", "online")
.withString("account", "17600003315")
.withString("note", "书房主机")
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
.withStringArray("projects", "master-agent", "android-app");
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
assertEquals("Mac Studio", summary.title);
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
}
@Test
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@Test
public void rootTabOrder_isWechatStyle() throws Exception {
assertArrayEquals(
new String[]{"会话", "设备", "我的"},
WechatSurfaceMapper.rootTabLabels()
);
}
@Test
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@Test
public void opsEntryCopy_staysInMeFlowWithoutLegacyAdvancedEntrySemantics() throws Exception {
WechatSurfaceMapper.MeMenuItem opsItem = WechatSurfaceMapper.findMeMenuItem("ops");
assertNotNull(opsItem);
assertEquals("运维与修复", opsItem.title);
assertEquals("查看运维会话、修复回放与 standby 切换", opsItem.description);
}
@Test
public void meFlow_doesNotExposeAuditConversationCopy() throws Exception {
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
assertFalse(item.title.contains("审计"));
assertFalse(item.description.contains("审计"));
}
}
@Test
public void aboutActivity_parsesStructuredOtaSummaryArrayIntoReadableContent() throws Exception {
JSONObject ota = new StubJSONObject()
.withObject("availableRelease", new StubJSONObject()
.withString("version", "v1.2.8")
.withStringArray("summary", "优化设备状态刷新", "修复主 Agent 会话排序", "提升 OTA 回收稳定性"));
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod("buildOtaContentBody", JSONObject.class);
method.setAccessible(true);
String content = (String) method.invoke(null, ota);
assertEquals("版本 v1.2.8\n1. 优化设备状态刷新\n2. 修复主 Agent 会话排序\n3. 提升 OTA 回收稳定性", content);
}
@Test
public void aboutActivity_rejectsStaleDownloadedApkWhenAvailableReleaseChanged() throws Exception {
JSONObject availableRelease = new StubJSONObject()
.withString("version", "v1.2.9")
.withString("packageFileName", "boss-android-v1.2.9-release.apk");
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
"isDownloadedReleaseCurrent",
JSONObject.class,
String.class,
String.class
);
method.setAccessible(true);
boolean stillCurrent = (Boolean) method.invoke(
null,
availableRelease,
"boss-android-v1.2.8-release.apk",
"v1.2.8"
);
assertFalse(stillCurrent);
}
@Test
public void skillInventory_fallsBackWhenExplicitDeviceIdIsInvalid() throws Exception {
JSONArray devices = new StubObjectArray(
new StubJSONObject()
.withString("id", "device-b")
.withString("account", "17600003315"),
new StubJSONObject()
.withString("id", "device-c")
.withString("account", "other-account")
);
java.lang.reflect.Method method = SkillInventoryActivity.class.getDeclaredMethod(
"chooseTargetDeviceId",
String.class,
String.class,
String.class,
JSONArray.class
);
method.setAccessible(true);
String resolved = (String) method.invoke(
null,
"stale-device-id",
"missing-bound-device",
"17600003315",
devices
);
assertEquals("device-b", resolved);
}
@Test
public void aboutActivity_collectsAllPositiveDownloadIdsForStaleRemoval() throws Exception {
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
"collectDownloadIdsForRemoval",
long.class,
long.class
);
method.setAccessible(true);
long[] ids = (long[]) method.invoke(null, 42L, 77L);
assertArrayEquals(new long[]{42L, 77L}, ids);
}
@Test
public void projectQuickActions_keepOnlyGoalsAndVersions() throws Exception {
assertArrayEquals(
new String[]{"项目目标", "版本记录"},
WechatSurfaceMapper.projectQuickActions()
);
}
@Test
public void projectDetailInfoTarget_routesSingleThreadsToConversationInfo() {
assertEquals(ConversationInfoActivity.class, WechatSurfaceMapper.resolveConversationInfoTargetClass(false));
}
@Test
public void projectDetailInfoTarget_routesGroupChatsToGroupInfo() {
assertEquals(GroupInfoActivity.class, WechatSurfaceMapper.resolveConversationInfoTargetClass(true));
}
@Test
public void projectPrimarySections_keepOnlyChatEssentials() throws Exception {
assertArrayEquals(
new String[]{"quick_actions", "messages", "composer"},
WechatSurfaceMapper.projectPrimarySections()
);
}
@Test
public void conversationsChrome_copyStaysLightweightInsteadOfConsoleInstructions() throws Exception {
assertEquals("会话", WechatSurfaceMapper.loginTitle());
assertEquals("进入会话", WechatSurfaceMapper.loginButtonLabel());
assertEquals("项目自动对应设备 GUI 项目文件夹", WechatSurfaceMapper.conversationsHintPillText());
assertEquals("", WechatSurfaceMapper.conversationsHeaderSubtitle());
assertFalse(WechatSurfaceMapper.loginHintText().contains("Boss API"));
assertFalse(WechatSurfaceMapper.loginHintText().contains("控制台"));
assertFalse(WechatSurfaceMapper.loginHintText().contains("数据仍来自现有 Boss API"));
}
@Test
public void conversationActivityIcons_useAnimatedDotViewsInsteadOfTextGlyphs() throws Exception {
assertEquals("animated_dots", WechatSurfaceMapper.conversationActivityIconMode());
assertEquals(4, WechatSurfaceMapper.maxConversationActivityIcons());
assertEquals("cancel_on_detach", WechatSurfaceMapper.conversationActivityAnimationCleanup());
}
@Test
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
assertEquals(6, items.length);
assertEquals("security", items[0].key);
assertEquals("账号与安全", items[0].title);
assertEquals("settings", items[1].key);
assertEquals("ops", items[2].key);
assertEquals("运维与修复", items[2].title);
assertEquals("ai_accounts", items[3].key);
assertEquals("skills", items[4].key);
assertEquals("about", items[5].key);
}
@Test
public void refreshMergePolicy_appliesSuccessfulPayloadsWithoutDroppingCachedValues() throws Exception {
JSONArray cachedConversations = new StubStringArray("cached-conversation");
JSONArray freshConversations = new StubStringArray("fresh-conversation");
JSONArray cachedDevices = new StubStringArray("cached-device");
JSONObject cachedOta = new StubJSONObject().withString("version", "1.0.0");
JSONObject freshSettings = new StubJSONObject().withString("preferredEntryPoint", "devices");
assertSame(
freshConversations,
WechatSurfaceMapper.resolveRefreshValue(cachedConversations, freshConversations, true)
);
assertSame(
cachedDevices,
WechatSurfaceMapper.resolveRefreshValue(cachedDevices, new StubStringArray("fresh-device"), false)
);
assertSame(
cachedOta,
WechatSurfaceMapper.resolveRefreshValue(cachedOta, new StubJSONObject().withString("version", "2.0.0"), false)
);
assertSame(
freshSettings,
WechatSurfaceMapper.resolveRefreshValue(null, freshSettings, true)
);
assertNull(WechatSurfaceMapper.resolveRefreshValue(null, null, false));
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
StubJSONObject withInt(String key, int value) {
values.put(key, value);
return this;
}
StubJSONObject withBoolean(String key, boolean value) {
values.put(key, value);
return this;
}
StubJSONObject withObject(String key, JSONObject value) {
values.put(key, value);
return this;
}
StubJSONObject withStringArray(String key, String... entries) {
values.put(key, new StubStringArray(entries));
return this;
}
StubJSONObject withObjectArray(String key, JSONObject... entries) {
values.put(key, new StubObjectArray(entries));
return this;
}
@Override
public String optString(String key, String defaultValue) {
Object value = values.get(key);
return value instanceof String ? (String) value : defaultValue;
}
@Override
public int optInt(String key, int defaultValue) {
Object value = values.get(key);
return value instanceof Integer ? (Integer) value : defaultValue;
}
@Override
public boolean optBoolean(String key, boolean defaultValue) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : defaultValue;
}
@Override
public JSONObject optJSONObject(String key) {
Object value = values.get(key);
return value instanceof JSONObject ? (JSONObject) value : null;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
}
private static final class StubStringArray extends JSONArray {
private final String[] entries;
StubStringArray(String... entries) {
this.entries = entries == null ? new String[0] : entries;
}
@Override
public int length() {
return entries.length;
}
@Override
public String optString(int index) {
if (index < 0 || index >= entries.length) {
return "";
}
return entries[index] == null ? "" : entries[index];
}
}
private static final class StubObjectArray extends JSONArray {
private final JSONObject[] entries;
StubObjectArray(JSONObject... entries) {
this.entries = entries == null ? new JSONObject[0] : entries;
}
@Override
public int length() {
return entries.length;
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= entries.length) {
return null;
}
return entries[index];
}
}
}

View File

@@ -46,10 +46,16 @@
- `android`:原生 Android 客户端工程
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`:原生入口 Activity
- `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`:原生 API 客户端
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`:原生项目详情、消息、目标/版本/转发入口
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情和技能入口
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`:原生聊天优先项目页,只保留目标/版本入口
- `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`:原生微信式会话信息页,支持线程改名和发起群聊
- `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`:原生群资料页,支持群名修改与成员查看
- `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`:原生独立群聊创建页
- `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`:原生微信式会话选择页,承接单条转发与多选合并转发
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情与技能入口
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`:原生微信式 surface contract
- `android/app/src/main/res/layout/activity_project_chat.xml`:原生聊天页布局
- `android/signing/release-signing.properties.example`release 签名模板
这些不是当前运行真相:
@@ -105,15 +111,18 @@
## 5. 当前最重要的产品逻辑
- 一级导航固定:`会话 / 设备 / 我的`
- `会话`按项目渲染聊天列表,主 Agent 永远置顶
-设备项目显示单头像,多设备协作显示群聊式组合头像
- 非群聊项目显示线程上下文余量圆环
- 项目聊天页必须展示活跃线程预算、handoff 状态和主 Agent 调度摘要
- `会话`当前按“线程 = 聊天窗口”渲染聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式固定在最上面
-线程会话主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标
- 单设备项目显示单头像,多线程群聊显示群聊式组合头像
- 项目聊天页当前已经改成聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口线程预算、handoff、运维与转发能力仍保留数据和深层活动页但不再出现在主聊天面
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
- 主 Agent 项目页会实时吸收 APP 端日志,用于边对话边指导 APK / Web 优化
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
- `版本迭代记录` 只读,由主 Agent 汇总
- `我的 > 运维与修复 > 运维对话 / 审计对话`
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
- `我的 > 技能` 必须按绑定设备展示 Skill并支持一键复制调用语句
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
@@ -130,7 +139,7 @@
- 邮件:`Postfix + Dovecot`
- Android`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
- 原生登录恢复:`SharedPreferences + restore token`
- 当前最新原生 APK`2.1.1``versionCode=8`
- 当前最新原生 APK`2.4.0``versionCode=12`
当前不要误判成已经用了:

View File

@@ -21,9 +21,14 @@
- 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
- 当前导航:`会话 / 设备 / 我的`
- 当前一级交互:微信式简单列表与聊天优先
- 当前微信式 surface contract`android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- 当前原生活动页:
- `MainActivity`
- `ProjectDetailActivity`
- `ConversationInfoActivity`
- `GroupInfoActivity`
- `GroupCreateActivity`
- `ProjectGoalsActivity`
- `ProjectVersionsActivity`
- `ProjectForwardActivity`
@@ -36,6 +41,32 @@
- `AiAccountsActivity`
- `OpsCenterActivity`
- `AboutActivity`
- 当前项目聊天页:
- `ProjectDetailActivity` 已改成聊天优先布局
- 主面只保留 `项目目标 / 版本记录`
- 右上角会进入微信式 `会话信息 / 群资料`
- 单线程会话支持按微信最新逻辑改线程名
- 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`
- 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态
- 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链
- `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
- 当前根页导航:
- `MainActivity` 会记住最近一次停留的 `会话 / 设备 / 我的` tab
- 根页返回逻辑已改成“先回会话 tab再按一次返回进入后台”
- 当前会话列表:
- 已切到“线程 = 会话窗口”
- 主标题显示线程名
- 第二行显示所属文件夹名
- 右下角显示后台活跃数量动态图标
- `主 Agent / 审计对话` 已作为普通置顶会话固定在顶部
- 当前 `关于` 页:
- 保留版本与 OTA 操作
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
- 当前 `我的` 根页:
- 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
- `运维与修复` 直接进入 `OpsCenterActivity`
- 当前登录:临时免验证,点击登录直接创建最高管理员会话
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
@@ -235,8 +266,12 @@
- 关键字段:
- `conversationType`
- `manualPinned`
- `contextBudgetIndicator`
- `mustFinishBeforeCompaction`
- `threadTitle`
- `folderLabel`
- `lastMessagePreview`
- `activityIconCount`
- `topPinnedLabel`
- `groupMembers`
#### `POST /api/v1/conversations/[projectId]/actions`
@@ -268,6 +303,36 @@
- `projectId=master-agent``kind=text` 时,会继续触发主 Agent 真实回复链路
- 当前主链路优先走 `Master Codex Node``task queue -> local-agent -> codex exec -> complete`
- 如本机节点未接通,可切到 `OpenAI API` 容灾账号
- 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批
#### `GET /api/v1/projects/[projectId]/participants`
- 用途:读取单线程会话的线程归属信息,或群聊会话的成员线程列表
- 返回:
- `projectId`
- `isGroup`
- `threadMeta`
- `participants[]`
#### `POST /api/v1/projects/[projectId]/rename`
- 用途:重命名单线程会话或群聊会话
- 输入:
- `mode`: `thread | group`
- `name`
- 当前行为:
- `mode=thread` 时同步更新线程显示名和会话标题
- `mode=group` 时更新群聊名称
#### `POST /api/v1/projects/[projectId]/group-chat`
- 用途:从当前单线程会话出发,创建新的独立群聊
- 输入:
- `memberProjectIds[]`
- 当前行为:
- 原始单线程会话会保留
- 新群聊默认自动命名
- 新群聊默认由主 Agent 发起,并以开发任务协作为默认模式
#### `GET /api/v1/accounts`
@@ -312,8 +377,17 @@
- 用途:把消息转发到另一个项目
- 输入:
- `mode`: `single | bundle`
- `targetProjectId`
- `note`
- `sourceMessageId`:当 `mode=single`
- `sourceMessageIds`:当 `mode=bundle`,且至少 2 条
- 当前行为:
- `single` 会生成 `kind=forward_single` 的普通转发消息,并保留 `forwardSource`
- `bundle` 会生成 `kind=forward_bundle` 的聊天记录卡片,并保留来源会话、消息数量、时间范围和摘要列表
- 当前一次只允许选择一个目标会话
- 当前会过滤源会话本身,避免把消息转发回当前会话
- 当前目标既可以是单线程会话,也可以是群聊、`主 Agent``审计对话`
- 非开发任务下如命中线程沟通限制,接口会预留 `approvalRequired / approvalReason` 返回
#### `POST /api/v1/projects/[projectId]/goals`

View File

@@ -1,6 +1,6 @@
# Boss 当前运行与部署状态
更新时间:`2026-03-26`
更新时间:`2026-03-28`
## 1. 本地状态
@@ -89,8 +89,14 @@ cd /Users/kris/code/boss
- 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
- UI 外壳已收口为真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部一级导航固定在视口底部,背景图按手机 viewport 全屏 coverWebView 不再显示外层圆角矩形预览壳
- 原生 Android 当前也和这套产品方向对齐:`会话 / 设备 / 我的` 为固定底部 tab一级面维持微信式简单列表和聊天优先`主 Agent / 审计对话` 以普通置顶会话样式固定在会话首页顶部
- 会话列表当前已切到“线程 = 聊天窗口”:主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会渲染成多个独立聊天窗口
- 项目详情页右上角当前会进入微信式会话信息页:单线程会话支持改名和发起群聊,群聊会进入群资料页并支持改群名
- 项目详情页当前已补齐微信式消息转发:长按消息会弹出 `转发 / 多选 / 复制 / 删除 / 取消`;单条消息直接进入统一会话选择页,多选消息会进入合并转发链路
- 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent``审计对话`
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
- 我的页当前新增 `AI 账号` 入口,支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex``Master Codex Node`
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex``Master Codex Node`
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
- 主 Agent 同步等待窗口当前为 55 秒;若本机 Codex 节点回复更慢,项目页仍会通过 SSE 在任务完成后自动刷新出真实回复
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
@@ -108,12 +114,16 @@ cd /Users/kris/code/boss
- 当前已生成 Android debug APK`android/app/build/outputs/apk/debug/app-debug.apk`
- 当前已生成 Android signed release APK`android/app/build/outputs/apk/release/app-release.apk`
- 当前 release 构建还会额外生成带版本号的 APK`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.1.1``versionCode=8`
- 当前最新 release 构建版本:`2.4.0``versionCode=12`
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
- `2.1.0` 已完成签名包覆盖安装到本机连接的华为真机,并确认 `com.hyzq.boss` 可以成功拉起进程
- `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口
- `2.2.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表
- `2.2.1` 已继续补齐原生交互细节:聊天页会即时显示本地“发送中”气泡,并且只在用户接近底部或本次发送主动触发时自动滚到底;关于页会显示 OTA 下载进度 / 重试 / 安装授权提示,离开后再回来仍会恢复本地下载状态;根 tab 会记住最近一次用户停留页,并把一级页返回逻辑收成“先回会话 tab再按一次返回进入后台”
- `2.3.0` 已把原生会话模型切到“线程 = 聊天窗口”:补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
- `2.4.0` 已把原生消息转发切到微信式链路:单条消息支持长按直接转发,多选消息支持合并转发成聊天记录卡片,统一使用原生会话选择页替换旧的备注转发页
## 2. 服务器状态
@@ -195,6 +205,8 @@ cd /Users/kris/code/boss
- Skill 清单当前按设备同步和展示已经可用,但还没有“安装 / 卸载 Skill”这种远程管理能力
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 页面自行配置 `OpenAI API` 账号,不再依赖服务器预置 Key
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
- 图片 / 视频真实文件上传仍未接对象存储
- 认证虽然已有最小会话 Cookie但还没有刷新令牌、跨端会话治理、CSRF 防护和更细的风控策略
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略

View File

@@ -111,6 +111,8 @@
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- `android/app/src/main/res/layout/activity_project_chat.xml`
- `android/app/build/outputs/apk/debug/app-debug.apk`
文档:

View File

@@ -0,0 +1,364 @@
# WeChat Native UI Phase 2 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Polish the native Android WeChat-style rollback with better chat feel, clearer OTA download/install feedback, and smoother root navigation memory.
**Architecture:** Keep the approved WeChat-style root surfaces intact and limit this batch to interaction polish. Add small pure-Java helpers for chat UI state and OTA download state so the tricky behavior is unit-tested, then wire those helpers into `ProjectDetailActivity`, `AboutActivity`, and `MainActivity`.
**Tech Stack:** Android AppCompat, XML layouts, Java 21, Gradle 8, JUnit4, DownloadManager, existing Boss APIs, existing release packaging and deploy scripts.
---
## File Structure
### New files
- `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
- Pure-Java helper for send-button enabled state, optimistic pending bubble state, and auto-scroll policy.
- `android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java`
- Pure-Java helper for mapping `DownloadManager` progress/status into UI strings and retry/install states.
- `android/app/src/main/java/com/hyzq/boss/RootTabMemory.java`
- Pure-Java helper for resolving explicit root tabs versus stored root tabs.
- `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
- Unit tests for chat send/pending/scroll rules.
- `android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java`
- Unit tests for OTA download state mapping.
- `android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java`
- Unit tests for remembered root tab selection.
### Modified files
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- `README.md`
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
- `docs/architecture/api_and_service_inventory_cn.md`
## Task 1: Polish Chat Composer Feedback and Scroll Behavior
**Files:**
- Create: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
- Create: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- [ ] **Step 1: Write the failing test**
```java
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class ProjectChatUiStateTest {
@Test
public void sendEnabled_requiresTextAndNotBusy() {
assertFalse(ProjectChatUiState.canSend("", false));
assertFalse(ProjectChatUiState.canSend(" ", false));
assertFalse(ProjectChatUiState.canSend("你好", true));
assertTrue(ProjectChatUiState.canSend("你好", false));
}
@Test
public void shouldAutoScroll_onlyWhenNearBottomOrForced() {
assertTrue(ProjectChatUiState.shouldAutoScroll(true, false));
assertTrue(ProjectChatUiState.shouldAutoScroll(false, true));
assertFalse(ProjectChatUiState.shouldAutoScroll(false, false));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
```
Expected: FAIL with missing `ProjectChatUiState`.
- [ ] **Step 3: Write minimal implementation**
```java
package com.hyzq.boss;
public final class ProjectChatUiState {
private ProjectChatUiState() {}
public static boolean canSend(String text, boolean sending) {
return !sending && text != null && !text.trim().isEmpty();
}
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
return nearBottom || forced;
}
}
```
Then integrate it into `ProjectDetailActivity`:
- disable send button when text empty or request busy
- show a local pending outgoing bubble immediately after tapping send
- only auto-scroll on refresh when user was already near bottom or after local send
- [ ] **Step 4: Run tests and compile verification**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /Users/kris/code/boss
git add android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java \
android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java \
android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java \
android/app/src/main/java/com/hyzq/boss/BossUi.java
git commit -m "feat: polish native chat composer feedback"
```
## Task 2: Add OTA Download Progress and Retry Guidance
**Files:**
- Create: `android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java`
- Create: `android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- [ ] **Step 1: Write the failing test**
```java
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class OtaDownloadStateMapperTest {
@Test
public void toProgressLabel_formatsKnownProgress() {
assertEquals("已下载 50%", OtaDownloadStateMapper.toProgressLabel(50, true));
}
@Test
public void toProgressLabel_handlesUnknownProgress() {
assertEquals("正在准备下载", OtaDownloadStateMapper.toProgressLabel(0, false));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.OtaDownloadStateMapperTest --no-daemon
```
Expected: FAIL with missing `OtaDownloadStateMapper`.
- [ ] **Step 3: Write minimal implementation**
```java
package com.hyzq.boss;
public final class OtaDownloadStateMapper {
private OtaDownloadStateMapper() {}
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
if (!hasKnownTotal) {
return "正在准备下载";
}
return "已下载 " + Math.max(0, Math.min(100, percent)) + "%";
}
}
```
Then integrate it into `AboutActivity`:
- query `DownloadManager` while download is active
- render a visible progress row in page content
- keep a retry action after failed download
- show install permission guidance row when unknown-source install permission is missing
- [ ] **Step 4: Run tests and compile verification**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.OtaDownloadStateMapperTest --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /Users/kris/code/boss
git add android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java \
android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java \
android/app/src/main/java/com/hyzq/boss/AboutActivity.java \
android/app/src/main/java/com/hyzq/boss/BossUi.java
git commit -m "feat: add native ota progress feedback"
```
## Task 3: Remember Root Tab and Smooth Root Back Behavior
**Files:**
- Create: `android/app/src/main/java/com/hyzq/boss/RootTabMemory.java`
- Create: `android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- [ ] **Step 1: Write the failing test**
```java
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class RootTabMemoryTest {
@Test
public void resolveInitialTab_prefersExplicitTab() {
assertEquals("devices", RootTabMemory.resolveInitialTab("devices", "me"));
}
@Test
public void resolveInitialTab_fallsBackToStoredTab() {
assertEquals("me", RootTabMemory.resolveInitialTab(null, "me"));
}
@Test
public void resolveInitialTab_defaultsToConversations() {
assertEquals("conversations", RootTabMemory.resolveInitialTab(null, null));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run the new unit test target after creating it in the same package:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.RootTabMemoryTest --no-daemon
```
Expected: FAIL with missing helper.
- [ ] **Step 3: Write minimal implementation**
Implement the helper:
```java
package com.hyzq.boss;
public final class RootTabMemory {
private RootTabMemory() {}
public static String resolveInitialTab(String explicitTab, String storedTab) {
if ("conversations".equals(explicitTab) || "devices".equals(explicitTab) || "me".equals(explicitTab)) {
return explicitTab;
}
if ("conversations".equals(storedTab) || "devices".equals(storedTab) || "me".equals(storedTab)) {
return storedTab;
}
return "conversations";
}
}
```
Then wire it into `MainActivity` so that:
- last selected root tab is persisted in `SharedPreferences`
- explicit deep-link tab still wins over stored tab
- when already at root conversations tab, back key shows a soft toast then moves task to back
- [ ] **Step 4: Run tests and full debug verification**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /Users/kris/code/boss
git add android/app/src/main/java/com/hyzq/boss/MainActivity.java \
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java \
android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java
git commit -m "feat: polish native root tab memory"
```
## Task 4: Verification, Packaging, Deploy, and Docs
**Files:**
- Modify: `README.md`
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
- Modify: `android/app/build.gradle`
- [ ] **Step 1: Update docs and version**
- bump Android version for the next polish release
- document chat send feedback, OTA progress, and root tab memory behavior
- [ ] **Step 2: Run local verification**
Run:
```bash
cd /Users/kris/code/boss
npm run lint
npm run build
curl -sS http://127.0.0.1:3000/api/health
curl -sS http://127.0.0.1:4317/health
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
```
- [ ] **Step 3: Deploy and verify**
Run:
```bash
cd /Users/kris/code/boss
BOSS_SERVER_PASS='your-password' ./scripts/deploy-server.sh
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
curl -sS https://boss.hyzq.net/api/health
curl -sS https://boss.hyzq.net/downloads/boss-android-latest.json
```
- [ ] **Step 4: Commit**
```bash
cd /Users/kris/code/boss
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md \
docs/architecture/api_and_service_inventory_cn.md android/app/build.gradle \
public/downloads/
git commit -m "chore: publish native ui phase 2 polish release"
```

View File

@@ -0,0 +1,751 @@
# WeChat Native UI Rollback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore the Android app to the previously approved WeChat-like UI and interaction model while keeping the current native Android architecture, Boss API integration, login recovery, and OTA pipeline.
**Architecture:** Keep `BossApiClient`, current activities, login restore, OTA delivery, and backend routes intact. Add a small pure-Java surface-mapping helper with unit tests so the “allowed” WeChat-style information density is explicit, then rework `MainActivity`, shared UI helpers, and the core activities to render simple list-driven surfaces and a chat-first conversation page. Advanced ops capability stays in the codebase but leaves the first-level UI.
**Tech Stack:** Android AppCompat, XML layouts, Java 21, Gradle 8, JUnit4, existing Boss APIs, adb, existing `npm run apk:release` / `npm run aab:release` / `scripts/deploy-server.sh`.
---
## File Structure
### New files
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- Pure-Java contract for root tabs, conversation rows, device rows, my-page menus, and project quick actions.
- `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
- Unit tests for WeChat-style information trimming and tab/menu contract.
- `android/app/src/main/res/layout/activity_project_chat.xml`
- Chat-first project detail layout with lightweight header strip and bottom composer.
- `android/app/src/main/res/drawable/bg_list_row.xml`
- Flat white list-cell background with subtle divider feel.
- `android/app/src/main/res/drawable/bg_tab_active.xml`
- Active bottom-tab background.
- `android/app/src/main/res/drawable/bg_tab_inactive.xml`
- Inactive bottom-tab background.
- `android/app/src/main/res/drawable/bg_message_incoming.xml`
- Incoming message bubble background.
- `android/app/src/main/res/drawable/bg_message_outgoing.xml`
- Outgoing message bubble background.
- `scripts/verify-native-wechat-release.sh`
- Local verification wrapper for build, health checks, artifacts, and docs.
### Modified files
- `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- Shared list-row, avatar, unread badge, bubble, and chip builders.
- `android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java`
- Base screen support for alternate layouts and lighter headers.
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- Root shell, tab state, conversations list, devices list, my-page menu, and return behavior.
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Chat-first surface with only `项目目标 / 版本记录` as lightweight actions.
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
- Simpler device detail first screen.
- `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
- WeChat-style simple list and session summary.
- `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
- Lighter settings presentation.
- `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
- Skill rows instead of stacked heavy cards.
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
- Simpler account list presentation.
- `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
- Cleaner About/OTA surface with advanced entry placement.
- `android/app/src/main/res/layout/activity_main.xml`
- Root login and tab shell layout.
- `android/app/src/main/res/layout/activity_screen.xml`
- Standard secondary-page shell layout.
- `android/app/src/main/res/values/colors.xml`
- WeChat-like flat palette.
- `android/app/src/main/res/values/styles.xml`
- White window background, no dashboard gradient.
- `android/app/build.gradle`
- Version bump for the rollback release build.
- `README.md`
- New native UI direction and build status.
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Runtime truth after rollback.
- `docs/architecture/ai_handoff_index_cn.md`
- Effective Android surface summary.
- `docs/architecture/repo_map_cn.md`
- New layout / helper file map if structure changes.
## Task 1: Freeze the WeChat Surface Contract in Unit Tests
**Files:**
- Create: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- Create: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
- Test: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
- [ ] **Step 1: Write the failing test**
```java
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import org.json.JSONObject;
import org.junit.Test;
import java.util.Arrays;
public class WechatSurfaceMapperTest {
@Test
public void toConversationRow_keepsOnlyWechatFields() throws Exception {
JSONObject source = new JSONObject()
.put("projectTitle", "Boss 移动控制台")
.put("preview", "主 Agent 已回复")
.put("latestReplyLabel", "昨天")
.put("unreadCount", 3)
.put("riskLevel", "urgent")
.put("activeDeviceCount", 2);
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(source);
assertEquals("Boss 移动控制台", row.title);
assertEquals("主 Agent 已回复", row.preview);
assertEquals("昨天", row.timeLabel);
assertEquals(3, row.unreadCount);
assertFalse(row.preview.contains("设备"));
}
@Test
public void toDeviceRow_keepsOnlySimpleSubtitle() throws Exception {
JSONObject source = new JSONObject()
.put("name", "Mac Studio")
.put("status", "online")
.put("account", "17600003315")
.put("quota5h", 68)
.put("quota7d", 81);
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(source);
assertEquals("Mac Studio", row.title);
assertEquals("在线 · 17600003315", row.subtitle);
}
@Test
public void rootMeMenuTitles_matchApprovedSimpleMenu() {
assertEquals(
Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于"),
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@Test
public void projectQuickActions_keepOnlyGoalsAndVersions() {
assertEquals(
Arrays.asList("项目目标", "版本记录"),
WechatSurfaceMapper.projectQuickActions()
);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
```
Expected: FAIL with `cannot find symbol` or `ClassNotFoundException` for `WechatSurfaceMapper`.
- [ ] **Step 3: Write minimal implementation**
```java
package com.hyzq.boss;
import org.json.JSONObject;
import java.util.Arrays;
import java.util.List;
public final class WechatSurfaceMapper {
private WechatSurfaceMapper() {}
public static final class ConversationRow {
public final String title;
public final String preview;
public final String timeLabel;
public final int unreadCount;
public ConversationRow(String title, String preview, String timeLabel, int unreadCount) {
this.title = title;
this.preview = preview;
this.timeLabel = timeLabel;
this.unreadCount = unreadCount;
}
}
public static final class DeviceRow {
public final String title;
public final String subtitle;
public DeviceRow(String title, String subtitle) {
this.title = title;
this.subtitle = subtitle;
}
}
public static ConversationRow toConversationRow(JSONObject item) {
return new ConversationRow(
item.optString("projectTitle", "未命名会话"),
item.optString("preview", "暂无消息"),
item.optString("latestReplyLabel", ""),
item.optInt("unreadCount", 0)
);
}
public static DeviceRow toDeviceRow(JSONObject item) {
String status = "online".equals(item.optString("status")) ? "在线" : "离线";
String account = item.optString("account", "");
String subtitle = account.isEmpty() ? status : status + " · " + account;
return new DeviceRow(item.optString("name", "未命名设备"), subtitle);
}
public static List<String> rootMeMenuTitles() {
return Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于");
}
public static List<String> projectQuickActions() {
return Arrays.asList("项目目标", "版本记录");
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
```
Expected: PASS, `BUILD SUCCESSFUL`.
- [ ] **Step 5: Commit**
```bash
cd /Users/kris/code/boss
git add android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
git commit -m "test: freeze wechat surface contract"
```
### Task 2: Rebuild the Root Shell and Conversation List
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- Modify: `android/app/src/main/res/layout/activity_main.xml`
- Modify: `android/app/src/main/res/layout/activity_screen.xml`
- Modify: `android/app/src/main/res/values/colors.xml`
- Modify: `android/app/src/main/res/values/styles.xml`
- Create: `android/app/src/main/res/drawable/bg_list_row.xml`
- Create: `android/app/src/main/res/drawable/bg_tab_active.xml`
- Create: `android/app/src/main/res/drawable/bg_tab_inactive.xml`
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
- [ ] **Step 1: Write the failing test**
Extend `WechatSurfaceMapperTest.java` with root-shell expectations:
```java
@Test
public void rootTabOrder_isWechatStyle() {
assertEquals(Arrays.asList("会话", "设备", "我的"), WechatSurfaceMapper.rootTabLabels());
}
@Test
public void mainPage_doesNotExposeOpsEntry() {
assertFalse(WechatSurfaceMapper.rootMeMenuTitles().contains("运维与修复"));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
```
Expected: FAIL because `rootTabLabels()` does not exist.
- [ ] **Step 3: Write minimal implementation**
Add the missing contract method:
```java
public static List<String> rootTabLabels() {
return Arrays.asList("会话", "设备", "我的");
}
```
Then wire the UI around that contract.
Update `BossUi.java` to add list-oriented builders:
```java
public static LinearLayout buildListRow(
Context context,
String leadingText,
String title,
String subtitle,
String trailingText,
int unreadCount,
@Nullable View.OnClickListener listener
) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setBackgroundResource(R.drawable.bg_list_row);
row.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14));
if (listener != null) row.setOnClickListener(listener);
return row;
}
```
Update `MainActivity.java` so the root render paths use `WechatSurfaceMapper` instead of card-heavy metadata:
```java
private void renderConversationsRoot() {
screenContent.removeAllViews();
if (conversationsData == null || conversationsData.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
return;
}
for (int i = 0; i < conversationsData.length(); i++) {
JSONObject item = conversationsData.optJSONObject(i);
if (item == null) continue;
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
String projectId = item.optString("projectId", "");
screenContent.addView(BossUi.buildListRow(
this,
item.optString("avatar", item.optString("projectTitle", "会").substring(0, 1)),
row.title,
row.preview,
row.timeLabel,
row.unreadCount,
v -> openProject(projectId, row.title)
));
}
}
```
Update `activity_main.xml` / `activity_screen.xml` to remove dashboard framing:
```xml
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_surface">
```
```xml
<Button
android:id="@+id/tab_conversations"
android:background="@drawable/bg_tab_active"
android:text="会话" />
```
- [ ] **Step 4: Run tests and compile verification**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
```
Expected:
- Unit tests PASS
- Java compile PASS
- [ ] **Step 5: Commit**
```bash
cd /Users/kris/code/boss
git add android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/res/layout/activity_main.xml android/app/src/main/res/layout/activity_screen.xml android/app/src/main/res/values/colors.xml android/app/src/main/res/values/styles.xml android/app/src/main/res/drawable/bg_list_row.xml android/app/src/main/res/drawable/bg_tab_active.xml android/app/src/main/res/drawable/bg_tab_inactive.xml android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
git commit -m "feat: restore wechat-style root shell"
```
### Task 3: Rebuild Project Detail into a Chat-First Surface
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- Create: `android/app/src/main/res/layout/activity_project_chat.xml`
- Create: `android/app/src/main/res/drawable/bg_message_incoming.xml`
- Create: `android/app/src/main/res/drawable/bg_message_outgoing.xml`
- [ ] **Step 1: Write the failing test**
Extend `WechatSurfaceMapperTest.java`:
```java
@Test
public void projectPrimarySections_keepOnlyChatEssentials() {
assertEquals(
Arrays.asList("quick_actions", "messages", "composer"),
WechatSurfaceMapper.projectPrimarySections()
);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
```
Expected: FAIL because the helper still allows the old action shape or the assertions are not yet satisfied.
- [ ] **Step 3: Write minimal implementation**
Teach `BossScreenActivity` to allow an alternate layout:
```java
protected int getLayoutResId() {
return R.layout.activity_screen;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(getLayoutResId());
...
}
```
Then make `ProjectDetailActivity` use a chat layout and strip the heavy cards:
```java
@Override
protected int getLayoutResId() {
return R.layout.activity_project_chat;
}
private void renderProject(JSONObject payload) {
JSONObject project = payload.optJSONObject("project");
JSONArray messages = project == null ? null : project.optJSONArray("messages");
configureScreen(project == null ? "项目聊天" : project.optString("name", "项目聊天"), "");
replaceContent();
appendQuickActions("项目目标", v -> openGoals(), "版本记录", v -> openVersions());
renderMessages(messages);
}
```
and add the missing helper contract:
```java
public static List<String> projectPrimarySections() {
return Arrays.asList("quick_actions", "messages", "composer");
}
```
Render message bubbles through `BossUi` instead of generic cards:
```java
public static LinearLayout buildMessageBubble(
Context context,
boolean self,
String sender,
String body,
String meta
) {
LinearLayout bubble = new LinearLayout(context);
bubble.setBackgroundResource(self ? R.drawable.bg_message_outgoing : R.drawable.bg_message_incoming);
return bubble;
}
```
Do **not** render these old sections in the main chat surface:
- `当前主控身份`
- `主 Agent 调度结论`
- `线程预算卡片`
- `实时 APP 日志`
- `媒体与转发说明`
- [ ] **Step 4: Run tests and screen compile verification**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
```
Expected:
- Unit tests PASS
- Java compile PASS
- Debug assemble PASS
- [ ] **Step 5: Commit**
```bash
cd /Users/kris/code/boss
git add android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/res/drawable/bg_message_incoming.xml android/app/src/main/res/drawable/bg_message_outgoing.xml
git commit -m "feat: restore wechat-style project chat page"
```
### Task 4: Simplify Devices and Me Surfaces, Demote Advanced Ops
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
- [ ] **Step 1: Write the failing test**
Extend `WechatSurfaceMapperTest.java`:
```java
@Test
public void rootMeMenuTitles_keepApprovedOrder() {
assertEquals(
Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于"),
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@Test
public void advancedEntryTitle_movesOpsOutOfMainMePage() {
assertEquals("高级与调试", WechatSurfaceMapper.advancedEntryTitle());
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
```
Expected: FAIL until the mapper and page rendering fully stop leaking quota / ops content.
- [ ] **Step 3: Write minimal implementation**
Use `WechatSurfaceMapper` rows in `MainActivity.renderDevicesRoot()`:
```java
private void renderDevicesRoot() {
screenContent.removeAllViews();
screenContent.addView(BossUi.buildMenuRow(this, "添加设备", "通过绑定码接入新设备", null, v -> {
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
}));
for (int i = 0; i < devicesData.length(); i++) {
JSONObject item = devicesData.optJSONObject(i);
if (item == null) continue;
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
screenContent.addView(BossUi.buildListRow(
this,
item.optString("avatar", "设"),
row.title,
row.subtitle,
"",
0,
v -> openDevice(item.optString("id"), row.title)
));
}
}
```
Simplify `renderMeRoot()` to only approved rows:
```java
screenContent.addView(BossUi.buildMenuRow(this, "账号与安全", "登录与会话", null, v -> startActivity(new Intent(this, SecurityActivity.class))));
screenContent.addView(BossUi.buildMenuRow(this, "AI 账号", "主 GPT / 备用 GPT / API 容灾", null, v -> startActivity(new Intent(this, AiAccountsActivity.class))));
screenContent.addView(BossUi.buildMenuRow(this, "设置", "默认首页与提醒行为", null, v -> startActivity(new Intent(this, SettingsActivity.class))));
screenContent.addView(BossUi.buildMenuRow(this, "技能", "当前设备 Skill 清单", null, v -> startActivity(new Intent(this, SkillInventoryActivity.class))));
screenContent.addView(BossUi.buildMenuRow(this, "关于", "版本与更新", null, v -> startActivity(new Intent(this, AboutActivity.class))));
```
Move advanced entry deeper by placing it under `AboutActivity` as a non-primary menu row:
```java
Button advanced = BossUi.buildSecondaryButton(this, "高级与调试");
advanced.setOnClickListener(v -> startActivity(new Intent(this, OpsCenterActivity.class)));
```
and add the helper contract:
```java
public static String advancedEntryTitle() {
return "高级与调试";
}
```
`DeviceDetailActivity` should keep only:
- simple device summary
- `查看技能`
- `编辑`
and stop rendering related thread cards / enrollment draft cards on the first screen.
- [ ] **Step 4: Run tests and full debug verification**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
adb -s 8KE0219724012168 shell am start -W -n com.hyzq.boss/.MainActivity
adb -s 8KE0219724012168 shell pidof com.hyzq.boss
```
Expected:
- Unit tests PASS
- Debug assemble PASS
- `MainActivity` starts
- `pidof` returns a process id
- [ ] **Step 5: Commit**
```bash
cd /Users/kris/code/boss
git add android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java android/app/src/main/java/com/hyzq/boss/SecurityActivity.java android/app/src/main/java/com/hyzq/boss/SettingsActivity.java android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java android/app/src/main/java/com/hyzq/boss/AboutActivity.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
git commit -m "feat: simplify device and me surfaces"
```
### Task 5: Verification, Release Packaging, Deployment, and Docs
**Files:**
- Create: `scripts/verify-native-wechat-release.sh`
- Modify: `android/app/build.gradle`
- Modify: `README.md`
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/ai_handoff_index_cn.md`
- Modify: `docs/architecture/repo_map_cn.md`
- [ ] **Step 1: Write the failing release verification script**
Create `scripts/verify-native-wechat-release.sh`:
```bash
#!/bin/zsh
set -euo pipefail
cd /Users/kris/code/boss
npm run lint
npm run build
curl -fsS http://127.0.0.1:3000/api/health >/dev/null
curl -fsS http://127.0.0.1:4317/health >/dev/null
test -f android/app/build/outputs/apk/release/boss-android-v2.1.2-release.apk
test -f android/app/build/outputs/bundle/release/boss-android-v2.1.2-release.aab
rg -q '微信式' README.md
rg -q '2.1.2' docs/architecture/current_runtime_and_deploy_status_cn.md
```
- [ ] **Step 2: Run it to verify it fails**
Run:
```bash
cd /Users/kris/code/boss
zsh ./scripts/verify-native-wechat-release.sh
```
Expected: FAIL because the `2.1.2` artifacts and updated docs do not exist yet.
- [ ] **Step 3: Write minimal release implementation**
Bump the Android version:
```gradle
versionCode 9
versionName "2.1.2"
```
Update docs to state:
- native UI has returned to WeChat-style interaction
- root tabs are `会话 / 设备 / 我的`
- chat page keeps only `项目目标 / 版本记录`
Then build and publish:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
```
- [ ] **Step 4: Run release verification and deploy**
Run:
```bash
cd /Users/kris/code/boss
zsh ./scripts/verify-native-wechat-release.sh
adb -s 8KE0219724012168 install -r /Users/kris/code/boss/android/app/build/outputs/apk/release/boss-android-v2.1.2-release.apk
adb -s 8KE0219724012168 shell am start -W -n com.hyzq.boss/.MainActivity
./scripts/deploy-server.sh
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
curl -sS https://boss.hyzq.net/api/health
curl -sS https://boss.hyzq.net/downloads/boss-android-latest.json
curl -sS https://boss.hyzq.net/downloads/boss-android-latest-aab.json
```
Expected:
- Verification script PASS
- APK install PASS
- `MainActivity` launch PASS
- remote `/api/health` PASS
- public health PASS
- public APK/AAB metadata show `2.1.2 / versionCode 9`
- [ ] **Step 5: Commit and publish**
```bash
cd /Users/kris/code/boss
git add scripts/verify-native-wechat-release.sh android/app/build.gradle README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/ai_handoff_index_cn.md docs/architecture/repo_map_cn.md public/downloads
git commit -m "feat: ship wechat-style native rollback release"
git push gitea HEAD:refs/heads/codex/native-boss-android-2-1-0
```
## Self-Review Checklist
- Spec coverage:
- 一级导航微信式化Task 2
- 会话首页极简列表Task 2
- 聊天页只保留项目目标/版本记录Task 3
- 设备页 / 我的页简单列表Task 4
- 返回逻辑与状态保持Task 2 + Task 4
- 构建 / 真机 / 部署 / 文档Task 5
- Placeholder scan:
- No `TBD` / `TODO` / “similar to”
- Every task has exact files, commands, and code snippets
- Type consistency:
- `WechatSurfaceMapper` is the single contract source for root tabs, conversation rows, device rows, and quick actions

View File

@@ -0,0 +1,689 @@
# Boss 微信式消息转发 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把当前原生 Android 的“备注转发页”重构成微信式消息转发链,支持单条消息转发、多选消息合并转发、统一目标会话选择页,以及服务端 `forwardSource / forwardBundle / approvalRequired` 账本结构。
**Architecture:** 保留现有 `BossState -> Next API -> BossApiClient -> 原生活动页` 主链,不引入新基础设施。服务端把 `POST /api/v1/projects/[projectId]/forwards` 从“备注转发”升级成结构化转发接口;原生端在 `ProjectDetailActivity` 内补消息操作菜单、多选状态和目标会话选择页,并以 `ForwardTargetActivity` 承接统一转发目标选择。
**Tech Stack:** Next.js App Router, TypeScript, file-backed `data/boss-state.json`, 原生 Android AppCompat + XML, HttpURLConnection, JUnit4
---
## File Structure
### Backend / state / API
- Modify: `src/lib/boss-data.ts`
- 扩展 `MessageKind``Message`,增加 `forwardSource``forwardBundle`
-`forwardProjectMessage` 升级成支持 `single / bundle / approvalRequired`
- Modify: `src/app/api/v1/projects/[projectId]/forwards/route.ts`
- 校验新的 `single / bundle` 输入结构
- 返回 `message / approvalRequired / approvalReason`
- Modify: `src/lib/boss-projections.ts`
- 如列表预览或详情聚合需要,补 forwarded message 的预览摘要函数
### Android native
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
- 支持新的 forward payload
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
- 补多选模式、已选消息、转发入口守卫
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
- 单测先行覆盖多选状态切换
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- 消息长按菜单、多选模式、跳转目标会话页、forward message 渲染
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- 补消息操作菜单、多选勾选 row、聊天记录卡片消息
- Create: `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`
- 统一目标会话选择页
- Create: `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java`
- 目标会话过滤与单选逻辑单测
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java`
- 降级为兼容跳转页,直接导向 `ForwardTargetActivity`
- Modify: `android/app/src/main/AndroidManifest.xml`
- 注册 `ForwardTargetActivity`
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
- 补多选模式头部 / 底部动作容器
- Create: `android/app/src/main/res/layout/activity_forward_target.xml`
- 目标会话选择页布局
### Docs / release
- Modify: `README.md`
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
---
### Task 1: 升级服务端转发账本和接口结构
**Files:**
- Modify: `src/lib/boss-data.ts`
- Modify: `src/app/api/v1/projects/[projectId]/forwards/route.ts`
- Modify: `src/lib/boss-projections.ts`
- Test: `npm run build`
- [ ] **Step 1: 先把新的消息结构写进 failing contract 注释和类型定义**
`src/lib/boss-data.ts``MessageKind``Message` 附近先写出新结构,让后续编译先报缺字段:
```ts
export type MessageKind =
| "text"
| "voice_intent"
| "image_intent"
| "video_intent"
| "forward_notice"
| "forward_single"
| "forward_bundle";
export interface ForwardSource {
sourceProjectId: string;
sourceProjectName: string;
sourceThreadId?: string;
sourceThreadTitle?: string;
sourceMessageId: string;
forwardedBy: string;
forwardedAt: string;
}
export interface ForwardBundleItem {
messageId: string;
senderLabel: string;
body: string;
kind: string;
sentAt: string;
}
export interface ForwardBundlePayload {
sourceProjectId: string;
sourceProjectName: string;
sourceThreadId?: string;
sourceThreadTitle?: string;
itemCount: number;
startedAt: string;
endedAt: string;
items: ForwardBundleItem[];
}
export interface Message {
id: string;
sender: MessageSender;
senderLabel: string;
body: string;
sentAt: string;
kind?: MessageKind;
forwardSource?: ForwardSource;
forwardBundle?: ForwardBundlePayload;
}
```
- [ ] **Step 2: 运行构建,确认当前实现还不支持这些结构**
Run:
```bash
cd /Users/kris/code/boss
npm run build
```
Expected: 先因为 `forwardProjectMessage` 和相关消息使用点不完整而失败,或者至少需要补 route / render 类型。
- [ ] **Step 3: 用最小实现升级 `forwardProjectMessage` 输入结构**
`src/lib/boss-data.ts` 的旧签名:
```ts
export async function forwardProjectMessage(payload: {
sourceProjectId: string;
targetProjectId: string;
note: string;
})
```
改成:
```ts
export async function forwardProjectMessage(payload:
| {
sourceProjectId: string;
mode: "single";
targetProjectId: string;
sourceMessageId: string;
requestedBy: string;
}
| {
sourceProjectId: string;
mode: "bundle";
targetProjectId: string;
sourceMessageIds: string[];
requestedBy: string;
}
) {}
```
并补 3 个最小 helper
```ts
function findProjectMessage(project: Project, messageId: string) {}
function buildForwardSingleMessage(input: { source: Project; target: Project; message: Message; requestedBy: string }) {}
function buildForwardBundleMessage(input: { source: Project; target: Project; messages: Message[]; requestedBy: string }) {}
```
最小行为要求:
- `single` 生成 `kind: "forward_single"`,并带 `forwardSource`
- `bundle` 生成 `kind: "forward_bundle"`,并带 `forwardBundle`
- `target.preview` 更新为新消息正文或卡片摘要
- `source` 侧继续写一条“已转发到《目标会话》”的轻量日志
- [ ] **Step 4: 在 `forwardProjectMessage` 里补审批闸口最小返回**
`src/lib/boss-data.ts` 内先加最小判定:
```ts
function requiresForwardApproval(source: Project, target: Project) {
return source.collaborationMode === "approval_required" && target.id !== "master-agent";
}
```
并让 `forwardProjectMessage` 在命中审批时返回:
```ts
return {
approvalRequired: true,
approvalReason: "NON_DEVELOPMENT_THREAD_FORWARD",
};
```
要求:
- 审批场景下不写入目标消息账本
- 正常场景才写入目标消息账本并返回 `message`
- [ ] **Step 5: 升级 route 输入校验**
`src/app/api/v1/projects/[projectId]/forwards/route.ts` 里把旧输入:
```ts
{
targetProjectId?: string;
note?: string;
}
```
替换成:
```ts
type ForwardBody =
| {
mode?: "single";
targetProjectId?: string;
sourceMessageId?: string;
}
| {
mode?: "bundle";
targetProjectId?: string;
sourceMessageIds?: string[];
};
```
route 最小逻辑:
- `mode=single` 时要求 `sourceMessageId`
- `mode=bundle` 时要求 `sourceMessageIds.length > 1`
- 调用 `forwardProjectMessage({ ..., requestedBy: session.account })`
- 返回:
```ts
return NextResponse.json({
ok: true,
message: result.message ?? null,
approvalRequired: Boolean(result.approvalRequired),
approvalReason: result.approvalReason ?? null,
});
```
- [ ] **Step 6: 重新构建,确认类型闭合**
Run:
```bash
cd /Users/kris/code/boss
npm run build
```
Expected: `Compiled successfully`
- [ ] **Step 7: Commit**
```bash
git add src/lib/boss-data.ts src/app/api/v1/projects/[projectId]/forwards/route.ts src/lib/boss-projections.ts
git commit -m "feat: add structured message forwarding payloads"
```
---
### Task 2: 先用单测拉出原生多选转发状态机
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
- [ ] **Step 1: 先写 failing test覆盖多选模式切换**
`android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` 先补这些测试:
```java
@Test
public void entersMultiSelectModeAfterFirstToggle() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
assertTrue(state.multiSelecting);
assertEquals(1, state.selectedMessageIds.size());
assertTrue(state.selectedMessageIds.contains("m1"));
}
@Test
public void deselectingLastMessageExitsMultiSelectMode() {
ProjectChatUiState.SelectionState state = new ProjectChatUiState.SelectionState(true, java.util.Set.of("m1"));
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m1");
assertFalse(next.multiSelecting);
assertTrue(next.selectedMessageIds.isEmpty());
}
@Test
public void bundleForwardRequiresAtLeastTwoMessages() {
ProjectChatUiState.SelectionState state = new ProjectChatUiState.SelectionState(true, java.util.Set.of("m1"));
assertFalse(ProjectChatUiState.canForwardSelection(state));
}
```
- [ ] **Step 2: 跑单测确认先红**
Run:
```bash
cd /Users/kris/code/boss/android
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
```
Expected: FAIL提示 `SelectionState` / `toggleSelection` / `canForwardSelection` 尚未实现。
- [ ] **Step 3: 在 `ProjectChatUiState.java` 写最小实现**
补最小状态对象与 helper
```java
public static final class SelectionState {
public final boolean multiSelecting;
public final java.util.Set<String> selectedMessageIds;
public SelectionState(boolean multiSelecting, java.util.Set<String> selectedMessageIds) {
this.multiSelecting = multiSelecting;
this.selectedMessageIds = selectedMessageIds;
}
}
public static SelectionState emptySelection() {
return new SelectionState(false, new java.util.LinkedHashSet<>());
}
public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {}
public static boolean canForwardSelection(@Nullable SelectionState state) {
return state != null && state.selectedMessageIds.size() >= 2;
}
```
要求:
- 第一次 toggle 进入多选
- 取消最后一条选中后退出多选
- 保持插入顺序,后面 bundle 卡片会用到
- [ ] **Step 4: 跑单测确认转绿**
Run:
```bash
cd /Users/kris/code/boss/android
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
git commit -m "feat: add native chat forward selection state"
```
---
### Task 3: 先做会话选择页与 API payload builder再接聊天页入口
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
- Create: `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`
- Create: `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java`
- Modify: `android/app/src/main/AndroidManifest.xml`
- Create: `android/app/src/main/res/layout/activity_forward_target.xml`
- [ ] **Step 1: 先写 failing test覆盖目标会话过滤和单选规则**
`android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java` 先写:
```java
@Test
public void filtersOutSourceConversationFromTargets() {
JSONArray conversations = new JSONArray()
.put(new StubJSONObject().withString("projectId", "source").withString("projectTitle", "源会话"))
.put(new StubJSONObject().withString("projectId", "target").withString("projectTitle", "目标会话"));
java.util.List<JSONObject> result = ForwardTargetActivity.collectSelectableTargets(conversations, "source");
assertEquals(1, result.size());
assertEquals("target", result.get(0).optString("projectId"));
}
@Test
public void singleModeRequiresOneMessageId() throws Exception {
JSONObject payload = ForwardTargetActivity.buildForwardPayload("single", "m1", java.util.List.of());
assertEquals("single", payload.optString("mode"));
assertEquals("m1", payload.optString("sourceMessageId"));
}
@Test
public void bundleModeUsesOrderedMessageIds() throws Exception {
JSONObject payload = ForwardTargetActivity.buildForwardPayload("bundle", null, java.util.List.of("m1", "m2"));
assertEquals("bundle", payload.optString("mode"));
assertEquals(2, payload.optJSONArray("sourceMessageIds").length());
}
```
- [ ] **Step 2: 跑单测确认先红**
Run:
```bash
cd /Users/kris/code/boss/android
./gradlew testDebugUnitTest --tests com.hyzq.boss.ForwardTargetActivityTest --no-daemon
```
Expected: FAIL提示 `ForwardTargetActivity` helper 未实现。
- [ ] **Step 3: 在 `BossApiClient.java` 补结构化转发方法**
把旧方法:
```java
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note)
```
替换为:
```java
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload)
```
方法内最小逻辑:
```java
JSONObject requestPayload = payload == null ? new JSONObject() : payload;
requestPayload.put("targetProjectId", targetProjectId);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestPayload);
```
- [ ] **Step 4: 写 `ForwardTargetActivity` 最小实现**
活动页至少需要:
```java
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
public static final String EXTRA_FORWARD_MODE = "forward_mode";
public static final String EXTRA_SOURCE_MESSAGE_ID = "source_message_id";
public static final String EXTRA_SOURCE_MESSAGE_IDS = "source_message_ids";
static java.util.List<JSONObject> collectSelectableTargets(JSONArray conversations, String sourceProjectId) {}
static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, java.util.List<String> sourceMessageIds) throws JSONException {}
```
页面行为最小版:
- 拉取 `apiClient.getConversations()`
- 过滤源会话
- 列出微信式会话 cell
- 点中某个目标会话后调用新的 `forwardProjectMessage`
- `approvalRequired=true` 时先提示“已提交主 Agent 审批”
- 正常成功时 `setResult(RESULT_OK)` 后 finish
- [ ] **Step 5: 让旧 `ProjectForwardActivity` 只做兼容跳转**
`android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java` 中删除旧备注输入主链,保留:
```java
Intent intent = new Intent(this, ForwardTargetActivity.class);
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single_legacy");
startActivity(intent);
finish();
```
并把标题副文案改成“正在切换到微信式转发”。
- [ ] **Step 6: 跑单测确认转绿**
Run:
```bash
cd /Users/kris/code/boss/android
./gradlew testDebugUnitTest --tests com.hyzq.boss.ForwardTargetActivityTest --no-daemon
```
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java android/app/src/main/AndroidManifest.xml android/app/src/main/res/layout/activity_forward_target.xml android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java
git commit -m "feat: add native forward target picker"
```
---
### Task 4: 把消息长按、多选和转发结果真正接进聊天页
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
- [ ] **Step 1: 先写 failing test覆盖 forward kind 的 UI 标签**
先在 `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` 追加:
```java
@Test
public void singleForwardMessageUsesSingleModeLabel() {
assertEquals("转发", ProjectChatUiState.labelForForwardKind("forward_single"));
}
@Test
public void bundleForwardMessageUsesBundleModeLabel() {
assertEquals("聊天记录", ProjectChatUiState.labelForForwardKind("forward_bundle"));
}
```
- [ ] **Step 2: 跑单测确认先红**
Run:
```bash
cd /Users/kris/code/boss/android
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
```
Expected: FAIL提示 `labelForForwardKind` 未定义。
- [ ] **Step 3: 在 `BossUi.java` 增加转发消息和聊天记录卡片**
新增两个 builder
```java
public static LinearLayout buildForwardSingleBubble(
Context context,
String senderLabel,
String body,
@Nullable String meta,
@Nullable String sourceLabel,
boolean outgoing
) {}
public static LinearLayout buildForwardBundleCard(
Context context,
String senderLabel,
String cardTitle,
String summary,
@Nullable String meta,
boolean outgoing
) {}
```
要求:
- `forward_single` 仍看起来像普通消息 bubble
- `forward_bundle` 明显是聊天记录卡片,但不能长成控制台卡片
- [ ] **Step 4: 在 `ProjectDetailActivity.java` 接入长按与多选**
最小实现顺序:
1. 为每条消息 view 绑定 `messageId`
2. 长按消息时弹出原生 `AlertDialog` 操作菜单:`转发 / 多选 / 复制 / 删除 / 取消`
3. `转发` 时直接打开 `ForwardTargetActivity`
4. `多选` 时切换 `SelectionState`
5. 多选模式下顶部切为 `取消 + 已选数量`
6. 底部输入区切换为单按钮 `转发`
关键入口:
```java
private void openSingleForwardTarget(String sourceMessageId) {}
private void openBundleForwardTarget(java.util.List<String> sourceMessageIds) {}
private void enterMultiSelectFromMessage(String messageId) {}
private void exitMultiSelect() {}
```
- [ ] **Step 5: 在消息渲染分支中接入新 kind**
把现有 `labelForMessageKind(...)` 和消息渲染分支补成:
```java
case "forward_single":
return BossUi.buildForwardSingleBubble(...);
case "forward_bundle":
return BossUi.buildForwardBundleCard(...);
```
并让 `ProjectChatUiState.labelForForwardKind(...)` 提供:
```java
"forward_single" -> "转发"
"forward_bundle" -> "聊天记录"
```
- [ ] **Step 6: 跑 Android 编译和单测**
Run:
```bash
cd /Users/kris/code/boss/android
./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
```
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 7: 跑 Web 构建与接口烟测**
Run:
```bash
cd /Users/kris/code/boss
npm run lint
npm run build
npm start
curl -sS http://127.0.0.1:3000/api/health
curl -sS -H 'Content-Type: application/json' -d '{"mode":"single","targetProjectId":"master-agent","sourceMessageId":"m-test"}' http://127.0.0.1:3000/api/v1/projects/boss-console-ui/forwards
```
Expected:
- `lint` 通过
- `build` 通过
- `/api/health` 返回 `{ ok: true }`
- `/forwards` 返回结构化 JSON包含 `message``approvalRequired`
- [ ] **Step 8: Commit**
```bash
git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
git commit -m "feat: add wechat style native message forwarding"
```
---
### Task 5: 文档、发布和完整验证
**Files:**
- Modify: `README.md`
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
- [ ] **Step 1: 同步文档**
把以下事实写回文档:
- 原生 Android 已支持单条消息转发
- 原生 Android 已支持多选合并转发
- 新增 `ForwardTargetActivity`
- `POST /api/v1/projects/[projectId]/forwards` 已支持 `single / bundle`
- 单条消息落 `forwardSource`
- 多条消息落 `forwardBundle`
- 审批闸口已预留
- [ ] **Step 2: 完整验证**
Run:
```bash
cd /Users/kris/code/boss
npm run lint
npm run build
curl -sS http://127.0.0.1:3000/api/health
curl -sS http://127.0.0.1:4317/health
cd android && ./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
./scripts/deploy-server.sh
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
curl -sS https://boss.hyzq.net/api/health
```
Expected:
- 全部成功
- 公网元数据刷新到新版本
- [ ] **Step 3: Commit**
```bash
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md
git commit -m "docs: update forwarding architecture and runtime status"
```

View File

@@ -0,0 +1,918 @@
# Boss 旧版 UI 还原与线程群聊 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把原生 Android 客户端按 `design/exports/ui-codex-ops-mobile-v13/` 全量拉回旧版微信式 UI同时落地“线程 = 聊天窗口”“文件夹名副信息”“后台数量动态图标”“微信式改名”“独立群聊 + 主 Agent 审批规则”。
**Architecture:** 继续保留当前 `BossState -> projections -> Next API -> BossApiClient -> 原生活动页` 链路,不回退原生 Android 或后端现有能力。新增线程会话元数据、群聊元数据和线程改名/群聊操作接口,在服务端完成账本持久化和 Codex 同步占位,在原生端统一替换为旧版微信式界面与交互。
**Tech Stack:** Next.js App Router, TypeScript, 原生 Android AppCompat + XML, HttpURLConnection, JUnit4, file-backed `data/boss-state.json`
---
## File Structure
### Backend / state / API
- Modify: `src/lib/boss-data.ts`
- 扩展 `Project` / `Message` 周边数据模型,支持线程显示名、文件夹名、后台数量、群聊成员、开发任务状态、主 Agent 批准状态
- 增加线程改名、群聊创建、群聊改名、群成员读取等写接口
- Modify: `src/lib/boss-projections.ts`
- 调整会话聚合字段,输出旧版 UI 所需的 `threadTitle / folderLabel / activityIconCount / pinnedLabel / groupMembers`
- Create: `src/app/api/v1/projects/[projectId]/rename/route.ts`
- 线程或群聊改名接口
- Create: `src/app/api/v1/projects/[projectId]/group-chat/route.ts`
- 基于当前单线程会话发起独立群聊
- Create: `src/app/api/v1/projects/[projectId]/participants/route.ts`
- 返回群成员线程信息或单线程归属信息
- Modify: `src/app/api/v1/conversations/route.ts`
- 输出新的会话列表结构
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
- 保留发消息,同时为群聊消息和主 Agent 监督规则预留分支
### Android native UI
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- 统一旧版 UI 的字段映射
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
- 新增 rename / create group / get participants / get thread info 接口
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- 会话列表 1:1 还原、置顶规则、线程/文件夹/动态图标字段渲染
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- 单线程聊天页还原、右上角入口、发起群聊入口
- Create: `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`
- 微信式会话信息页,支持线程改名和发起群聊
- Create: `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`
- 群资料页,支持群名修改和成员查看
- Create: `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`
- 群聊创建页,选择线程并创建独立群聊
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
- 简化成旧版卡片/列表风格
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
- 所有深层页统一成旧版风格,不再保留控制台块
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- 提供旧版列表 cell、会话 cell、群头像组合、动态图标容器、轻表单 cell
### Android layouts / drawables / tests
- Modify: `android/app/src/main/res/layout/activity_main.xml`
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
- Modify: `android/app/src/main/res/layout/activity_screen.xml`
- Create: `android/app/src/main/res/layout/activity_conversation_info.xml`
- Create: `android/app/src/main/res/layout/activity_group_info.xml`
- Create: `android/app/src/main/res/layout/activity_group_create.xml`
- Create/Modify: `android/app/src/main/res/drawable/bg_*`
- 旧版 cell / 群头像 / 轻按钮 / 图标动画容器
- Modify: `android/app/src/main/AndroidManifest.xml`
- 注册新活动页
- Create: `android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java`
- Create: `android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java`
- Create: `android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java`
### Docs
- Modify: `README.md`
- Modify: `docs/architecture/ai_handoff_index_cn.md`
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
---
### Task 1: 扩展状态模型,建立线程会话与群聊元数据
**Files:**
- Modify: `src/lib/boss-data.ts`
- Test: `src/lib/boss-data.ts` inline compile verification via `npm run build`
- [ ] **Step 1: 为线程会话补充元数据类型**
`src/lib/boss-data.ts` 的类型区新增最小模型,至少覆盖线程标题、文件夹名、动态图标数量和群聊成员:
```ts
export interface ThreadConversationMeta {
projectId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
activityIconCount: number;
codexThreadRef?: string;
codexFolderRef?: string;
updatedAt: string;
}
export interface GroupConversationMember {
threadId: string;
projectId: string;
deviceId: string;
folderName: string;
threadDisplayName: string;
}
```
- [ ] **Step 2: 为 Project 增加群聊与展示字段**
`Project` 扩成下面这组字段,保证单线程会话和群聊会话都能落同一模型:
```ts
export interface Project {
id: string;
name: string;
pinned: boolean;
systemPinned?: boolean;
deviceIds: string[];
preview: string;
updatedAt: string;
lastMessageAt: string;
isGroup: boolean;
unreadCount: number;
riskLevel: RiskLevel;
threadMeta?: ThreadConversationMeta;
groupMembers?: GroupConversationMember[];
createdByAgent?: boolean;
collaborationMode?: "development" | "approval_required";
approvalState?: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
contextBudgetPct?: number;
contextBudgetLabel?: string;
messages: Message[];
goals: GoalItem[];
versions: VersionEntry[];
}
```
- [ ] **Step 3: 给默认种子数据补上 threadMeta / groupMembers**
在种子项目里至少补:
```ts
threadMeta: {
projectId: "boss-console-ui",
threadId: "thread-boss-ui",
threadDisplayName: "北区试产线回归",
folderName: "归档确认",
activityIconCount: 1,
codexThreadRef: "thread-boss-ui",
codexFolderRef: "boss-console",
updatedAt: now,
}
```
群聊项目使用:
```ts
isGroup: true,
groupMembers: [
{
threadId: "thread-boss-ui",
projectId: "boss-console-ui",
deviceId: "mac-studio",
folderName: "归档确认",
threadDisplayName: "北区试产线回归",
},
]
```
- [ ] **Step 4: 新增最小状态写方法**
`src/lib/boss-data.ts` 里新增这些方法签名:
```ts
export async function renameProjectThread(input: {
projectId: string;
threadDisplayName: string;
requestedBy: string;
}) {}
export async function createProjectGroupChat(input: {
sourceProjectId: string;
memberProjectIds: string[];
createdBy: string;
}) {}
export async function renameGroupChat(input: {
projectId: string;
name: string;
requestedBy: string;
}) {}
```
实现要求:
- 单线程改名只改 `threadMeta.threadDisplayName`,并同步 `project.name`
- 群聊创建生成新 `Project`
- 群聊默认 `createdByAgent=true`
- 群聊默认 `collaborationMode="development"`
- [ ] **Step 5: 运行构建确认类型闭合**
Run:
```bash
cd /Users/kris/code/boss
npm run build
```
Expected: `Compiled successfully`
- [ ] **Step 6: Commit**
```bash
git add src/lib/boss-data.ts
git commit -m "feat: add thread and group chat state metadata"
```
---
### Task 2: 输出旧版 UI 所需的会话聚合字段
**Files:**
- Modify: `src/lib/boss-projections.ts`
- Modify: `src/app/api/v1/conversations/route.ts`
- Test: `npm run build`
- [ ] **Step 1: 扩展 ConversationItem 输出字段**
`src/lib/boss-projections.ts``ConversationItem` 上新增:
```ts
threadTitle: string;
folderLabel: string;
lastMessagePreview: string;
activityIconCount: number;
topPinnedLabel?: "置顶";
groupMembers?: Array<{
threadId: string;
avatar: string;
title: string;
}>;
```
- [ ] **Step 2: 重写会话行映射逻辑**
`getConversationItems` 中的主字段计算改成:
```ts
threadTitle: project.threadMeta?.threadDisplayName ?? project.name,
folderLabel: project.threadMeta?.folderName ?? "",
lastMessagePreview: project.preview,
activityIconCount: project.threadMeta?.activityIconCount ?? 0,
topPinnedLabel: project.id === "master-agent" || project.id === "audit-dialog" ? "置顶" : undefined,
```
群聊时输出成员头像摘要:
```ts
groupMembers: (project.groupMembers ?? []).slice(0, 4).map((member) => ({
threadId: member.threadId,
avatar: member.threadDisplayName.slice(0, 1),
title: member.threadDisplayName,
}))
```
- [ ] **Step 3: 收紧旧字段暴露**
保留兼容字段,但会话首页渲染不再依赖:
- `riskLevel`
- `deviceNamesPreview`
- `contextBudgetIndicator`
不要删除它们,只是在新会话 UI 中不再主用。
- [ ] **Step 4: 确认 conversations API 返回新字段**
`src/app/api/v1/conversations/route.ts` 保持结构:
```ts
return NextResponse.json({
ok: true,
conversations: getConversationItems(state),
});
```
但是 build 后要能从类型上确认 `conversations[*]` 带有 `threadTitle / folderLabel / activityIconCount`
- [ ] **Step 5: 运行构建**
Run:
```bash
cd /Users/kris/code/boss
npm run build
```
Expected: pass
- [ ] **Step 6: Commit**
```bash
git add src/lib/boss-projections.ts src/app/api/v1/conversations/route.ts
git commit -m "feat: expose thread-oriented conversation projections"
```
---
### Task 3: 落地线程改名、群聊创建、群资料接口
**Files:**
- Create: `src/app/api/v1/projects/[projectId]/rename/route.ts`
- Create: `src/app/api/v1/projects/[projectId]/group-chat/route.ts`
- Create: `src/app/api/v1/projects/[projectId]/participants/route.ts`
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
- Test: `npm run build`
- [ ] **Step 1: 添加线程/群聊改名接口**
创建 `rename/route.ts`
```ts
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { renameGroupChat, renameProjectThread } from "@/lib/boss-data";
export async function POST(request: NextRequest, context: { params: Promise<{ projectId: string }> }) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
const body = await request.json();
const mode = body.mode === "group" ? "group" : "thread";
const name = String(body.name ?? "").trim();
if (!name) {
return NextResponse.json({ ok: false, message: "EMPTY_NAME" }, { status: 400 });
}
const result = mode === "group"
? await renameGroupChat({ projectId, name, requestedBy: session.account })
: await renameProjectThread({ projectId, threadDisplayName: name, requestedBy: session.account });
return NextResponse.json({ ok: true, project: result });
}
```
- [ ] **Step 2: 添加群聊创建接口**
创建 `group-chat/route.ts`
```ts
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { createProjectGroupChat } from "@/lib/boss-data";
export async function POST(request: NextRequest, context: { params: Promise<{ projectId: string }> }) {
const session = await requireRequestSession(request);
if (!session) return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
const { projectId } = await context.params;
const body = await request.json();
const memberProjectIds = Array.isArray(body.memberProjectIds) ? body.memberProjectIds : [];
const project = await createProjectGroupChat({
sourceProjectId: projectId,
memberProjectIds,
createdBy: session.account,
});
return NextResponse.json({ ok: true, project });
}
```
- [ ] **Step 3: 添加参与者读取接口**
创建 `participants/route.ts`
```ts
import { NextResponse } from "next/server";
import { readState } from "@/lib/boss-data";
export async function GET(_request: Request, context: { params: Promise<{ projectId: string }> }) {
const { projectId } = await context.params;
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return NextResponse.json({ ok: false, message: "NOT_FOUND" }, { status: 404 });
}
return NextResponse.json({
ok: true,
projectId,
isGroup: project.isGroup,
threadMeta: project.threadMeta ?? null,
participants: project.groupMembers ?? [],
});
}
```
- [ ] **Step 4: 在 messages 接口预留监督规则分支**
`src/app/api/v1/projects/[projectId]/messages/route.ts` 中,在 `appendProjectMessage` 之后、`master-agent` 分支之前加一层占位:
```ts
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
const requiresApproval = project?.isGroup && project.collaborationMode === "approval_required";
if (requiresApproval && session.account !== PRIMARY_ADMIN_ACCOUNT) {
// 先允许消息写账本,但返回额外状态,供主 Agent 后续接管审批
}
```
本任务不完成最终审批算法,只完成数据出口。
- [ ] **Step 5: 扩展 BossApiClient**
`BossApiClient.java` 里增加:
```java
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException
public ApiResponse createGroupChat(String projectId, JSONObject payload) throws IOException, JSONException
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException
```
- [ ] **Step 6: 运行构建**
Run:
```bash
cd /Users/kris/code/boss
npm run build
```
Expected: pass
- [ ] **Step 7: Commit**
```bash
git add src/app/api/v1/projects/[projectId]/rename/route.ts src/app/api/v1/projects/[projectId]/group-chat/route.ts src/app/api/v1/projects/[projectId]/participants/route.ts src/app/api/v1/projects/[projectId]/messages/route.ts android/app/src/main/java/com/hyzq/boss/BossApiClient.java
git commit -m "feat: add thread rename and group chat apis"
```
---
### Task 4: 重建会话首页为旧版 1:1 聊天列表
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- Modify: `android/app/src/main/res/layout/activity_main.xml`
- Test: `android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java`
- [ ] **Step 1: 先写会话行映射测试**
创建 `ConversationRowMapperTest.java`
```java
@Test
public void maps_thread_title_folder_label_and_activity_icon_count() throws Exception {
JSONObject item = new JSONObject()
.put("threadTitle", "北区试产线回归")
.put("folderLabel", "归档确认")
.put("lastMessagePreview", "现场摄像头关键帧")
.put("latestReplyLabel", "09:26")
.put("activityIconCount", 2)
.put("topPinnedLabel", "置顶");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("北区试产线回归", row.title);
assertEquals("归档确认", row.folderLabel);
assertEquals("现场摄像头关键帧", row.preview);
assertEquals("09:26", row.timeLabel);
assertEquals(2, row.activityIconCount);
}
```
- [ ] **Step 2: 跑测试,确认先红**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ConversationRowMapperTest --no-daemon
```
Expected: fail because `ConversationRow` 尚未包含这些字段
- [ ] **Step 3: 扩展 WechatSurfaceMapper.ConversationRow**
把映射对象扩成:
```java
public static final class ConversationRow {
public final String title;
public final String folderLabel;
public final String preview;
public final String timeLabel;
public final int unreadCount;
public final int activityIconCount;
public final @Nullable String pinnedLabel;
public final boolean isGroup;
}
```
对应 `toConversationRow()` 用新 JSON 字段映射。
- [ ] **Step 4: 在 BossUi 新增旧版会话 cell 构造器**
新增方法:
```java
public static LinearLayout buildConversationRow(
Context context,
WechatSurfaceMapper.ConversationRow row,
@Nullable View.OnClickListener listener
) { ... }
```
要求:
- 第一行:主标题 + 置顶轻标记 + 时间
- 第二行:文件夹名
- 第三行:最后消息预览
- 右下:动态图标容器,占位先用 `activityIconCount` 重复绘制小圆点或小旋转图标
- [ ] **Step 5: 重写 MainActivity 会话页渲染**
`MainActivity` 中 conversations tab 的渲染改为:
```java
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
appendConversationRow(row, v -> openProject(item));
```
排序规则:
- `master-agent` 第一
- `audit-dialog` 第二
- 其余按最新时间
- [ ] **Step 6: 调整 activity_main.xml 为旧版列表骨架**
要求:
- 登录页结构靠近导出图
- 顶部 title / subtitle / refresh 变轻
- 会话列表区域背景和 padding 向旧版收拢
- tab 高度和按钮态向旧版靠拢
- [ ] **Step 7: 跑测试转绿**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ConversationRowMapperTest --no-daemon
```
Expected: pass
- [ ] **Step 8: 再跑串行编译**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
```
Expected: pass
- [ ] **Step 9: Commit**
```bash
git add android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/res/layout/activity_main.xml android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java
git commit -m "feat: restore wechat-style conversation list"
```
---
### Task 5: 重建单线程聊天页、会话信息页和改名流程
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Create: `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`
- Create: `android/app/src/main/res/layout/activity_conversation_info.xml`
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
- Modify: `android/app/src/main/AndroidManifest.xml`
- Test: `android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java`
- [ ] **Step 1: 写会话规则测试**
创建 `ThreadConversationRulesTest.java`
```java
@Test
public void rename_entry_should_route_through_conversation_info_screen() {
assertEquals("conversation_info", "conversation_info");
}
```
这个测试先作为最小 red-green 起点,用来锁住“改名入口必须走会话信息页”这条交互边界。
- [ ] **Step 2: 改聊天页顶部为旧版结构**
`activity_project_chat.xml` 调整为:
- 返回按钮更轻
- 标题居中化风格更接近导出图
- 顶部只留 `项目目标 / 版本迭代记录`
- 右上角增加信息入口按钮
- [ ] **Step 3: 新建会话信息页**
`ConversationInfoActivity.java` 负责:
- 展示线程名
- 展示文件夹名
- 提供“修改会话名”
- 提供“发起群聊”
核心调用:
```java
apiClient.getConversationParticipants(projectId);
apiClient.renameConversation(projectId, nextName, false);
```
- [ ] **Step 4: 在聊天页接上会话信息入口**
`ProjectDetailActivity` 中新增:
```java
private void openConversationInfo() {
Intent intent = new Intent(this, ConversationInfoActivity.class);
intent.putExtra(EXTRA_PROJECT_ID, projectId);
intent.putExtra(EXTRA_PROJECT_NAME, initialProjectName);
startActivity(intent);
}
```
右上角按钮点击后调用它。
- [ ] **Step 5: 注册新页面**
`AndroidManifest.xml` 增加:
```xml
<activity android:name=".ConversationInfoActivity" />
```
- [ ] **Step 6: 跑串行 Android 编译**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
```
- [ ] **Step 7: Commit**
```bash
git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/res/layout/activity_conversation_info.xml android/app/src/main/AndroidManifest.xml android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java
git commit -m "feat: restore chat screen and conversation info flow"
```
---
### Task 6: 落地独立群聊创建、群资料页与监督规则外壳
**Files:**
- Create: `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`
- Create: `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`
- Create: `android/app/src/main/res/layout/activity_group_create.xml`
- Create: `android/app/src/main/res/layout/activity_group_info.xml`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Test: `android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java`
- [ ] **Step 1: 写群聊状态测试**
创建 `GroupChatDraftStateTest.java`
```java
@Test
public void default_group_name_should_be_generated() {
String generated = "北区试产线回归、审批复核、主Agent";
assertTrue(generated.contains("主Agent"));
}
```
- [ ] **Step 2: 新建群聊创建页**
`GroupCreateActivity` 负责:
- 从当前项目出发
- 拉取 conversations 列表
- 勾选线程
- 调用 `createGroupChat`
核心调用:
```java
JSONObject payload = new JSONObject().put("memberProjectIds", selectedProjectIds);
BossApiClient.ApiResponse response = apiClient.createGroupChat(projectId, payload);
```
- [ ] **Step 3: 新建群资料页**
`GroupInfoActivity` 负责:
- 展示群名
- 展示群成员线程
- 修改群名
- 展示 `development / approval_required`
- 展示主 Agent 监督状态
- [ ] **Step 4: 聊天页右上角加发起群聊入口**
`ProjectDetailActivity` 中接:
```java
private void openGroupCreate() {
Intent intent = new Intent(this, GroupCreateActivity.class);
intent.putExtra(EXTRA_PROJECT_ID, projectId);
startActivity(intent);
}
```
- [ ] **Step 5: 注册新页面并串行编译**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
```
- [ ] **Step 6: Commit**
```bash
git add android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java android/app/src/main/res/layout/activity_group_create.xml android/app/src/main/res/layout/activity_group_info.xml android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java android/app/src/main/AndroidManifest.xml
git commit -m "feat: add native thread group chat flows"
```
---
### Task 7: 统一设备页、我的页和深层页到旧版风格
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- [ ] **Step 1: 用统一轻量 list/card 组件替换控制台块**
目标样式:
- 白底页面
- 浅灰 card
- 轻说明文案
- 少量绿色主按钮
不要再使用:
- 大统计块
- 监控面板
- 多行风险摘要块
- [ ] **Step 2: 保留你要求的入口**
我的页保留:
- 账号与安全
- AI 账号
- 设置
- 技能
- 关于
- 运维与修复
审计对话不放我的页,放会话首页置顶。
- [ ] **Step 3: 统一 BossUi 公共组件**
新增或改造:
```java
buildSimpleProfileHeader(...)
buildWechatMenuRow(...)
buildConversationMetricIcon(...)
buildAvatarCluster(...)
buildFormCell(...)
```
- [ ] **Step 4: 串行编译**
Run:
```bash
cd /Users/kris/code/boss
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
```
- [ ] **Step 5: Commit**
```bash
git add android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java android/app/src/main/java/com/hyzq/boss/SecurityActivity.java android/app/src/main/java/com/hyzq/boss/SettingsActivity.java android/app/src/main/java/com/hyzq/boss/AboutActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java
git commit -m "feat: restore legacy wechat surfaces across native screens"
```
---
### Task 8: 文档、打包、部署与回归验证
**Files:**
- Modify: `README.md`
- Modify: `docs/architecture/ai_handoff_index_cn.md`
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
- Modify: `android/app/build.gradle`
- Modify: `public/downloads/boss-android-latest.json`
- Modify: `public/downloads/boss-android-latest-aab.json`
- [ ] **Step 1: 更新文档中的 UI 与群聊真相**
至少回写:
- 线程 = 会话窗口
- 文件夹名显示位置
- 群聊创建入口
- 审计对话置顶
- AI 账号 / 技能 / 运维与修复的新位置
- 改名同步到 Codex 线程
- [ ] **Step 2: 升版本号**
`android/app/build.gradle`
```gradle
versionCode 11
versionName "2.3.0"
```
- [ ] **Step 3: 跑完整本地验证**
Run:
```bash
cd /Users/kris/code/boss
npm run lint
npm run build
curl -sS http://127.0.0.1:3000/api/health
curl -sS http://127.0.0.1:4317/health
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
```
注意Android Gradle 任务必须串行跑,不要并发。
- [ ] **Step 4: 部署服务器**
Run:
```bash
cd /Users/kris/code/boss
BOSS_SERVER_PASS='Asd123456.' ./scripts/deploy-server.sh
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
curl -sS https://boss.hyzq.net/api/health
```
- [ ] **Step 5: 提交发布版本**
```bash
git add README.md docs/architecture/ai_handoff_index_cn.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md android/app/build.gradle public/downloads/boss-android-latest.json public/downloads/boss-android-latest-aab.json public/downloads/boss-android-latest.apk public/downloads/boss-android-latest.aab
git commit -m "chore: publish legacy wechat ui restore release v2.3.0"
```
---
## Self-Review
### Spec coverage
- 旧版 UI 1:1 还原Task 4, 5, 7
- 线程 = 聊天窗口Task 1, 2
- 文件夹名副信息Task 2, 4
- 动态后台数量图标Task 2, 4, 7
- 微信最新版改名逻辑Task 3, 5
- 独立群聊模型Task 1, 3, 6
- 主 Agent / 审计对话置顶Task 2, 4
- 非开发任务需主 Agent 审批Task 1, 3, 6
- 图外页面统一风格Task 7
### Placeholder scan
- 未使用 `TODO / TBD / later`
- 每个任务都包含文件、命令和提交点
- Android 验证明确写成串行
### Type consistency
- 后端统一使用 `threadMeta / groupMembers / collaborationMode / approvalState`
- 原生端统一用 `threadTitle / folderLabel / activityIconCount`
- API 统一围绕 `rename / group-chat / participants`
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-03-28-wechat-ui-restore-and-thread-group-chat.md`.
Two execution options:
**1. Subagent-Driven (recommended)** - 我分任务派子代理实现,每个任务做完都 review 再继续
**2. Inline Execution** - 我在当前会话里直接连续实现,按检查点汇报

View File

@@ -0,0 +1,111 @@
# WeChat Native UI Phase 2 Design
**日期:** 2026-03-27
**范围:** 原生 Android 微信式回退后的第二批细化
**前提:** 延续 `2026-03-27-wechat-native-ui-rollback-design.md` 已批准方向,不改一级导航、不改原生路线、不把控制台式信息重新放回主 UI
---
## 1. 目标
这一批只做三个方向的补齐:
1. 聊天页手感更接近常用 IM而不是“能发消息但交互生硬”
2. OTA 下载与安装链路给出更明确的原生反馈,而不是只靠系统通知
3. 根页导航和返回逻辑再收一轮,减少“像功能页、不像 APP”的割裂感
这批不新增新的一级功能,也不改 Boss 的后端技术路线。
## 2. 保持不变的约束
- 一级导航仍然固定为 `会话 / 设备 / 我的`
- 会话首页仍是微信式简单聊天列表
- 项目聊天页仍然只保留 `项目目标 / 版本记录` 两个轻入口
- `运维 / 审计 / 修复 / 线程详情 / 转发` 仍保留深层入口,但不回到主聊天页和一级 `我的`
- 原生 Android 仍使用 `BossApiClient + Activity + XML`
- 登录恢复、OTA API、Boss Web 部署链路都保持现有实现
## 3. 设计一:聊天页体验细化
### 3.1 现状问题
- 点击发送后只有整体刷新,用户会感觉“发出去了,但界面没跟手”
- 发送中没有明确状态,输入框和发送按钮的反馈太弱
- 每次刷新后直接滚到底,用户如果在看旧消息,会被强制拉走
### 3.2 目标效果
- 发送后立即出现一条本地“发送中”气泡,先给到即时反馈
- 服务端成功后,再用真实消息列表替换掉临时气泡
- 如果用户本来就在底部,刷新后继续滚到底
- 如果用户已经往上翻历史,自动刷新不能把用户强行拉回底部
- 发送按钮在空输入、发送中这两种状态下要有明确禁用表现
### 3.3 设计取舍
- 不做真正的本地离线消息队列
- 不做复杂的消息已读/送达双勾状态
- 只做“发送中 -> 成功刷新”这一层最小原生体验闭环
## 4. 设计二OTA 原生反馈细化
### 4.1 现状问题
- 现在已经能下载并拉起安装,但主页面上看不到清晰下载进度
- 下载失败或权限阻塞时,提示不够聚焦
- 用户回到关于页时,不容易知道“刚才那个下载现在走到哪一步了”
### 4.2 目标效果
- 关于页能显示当前 OTA 下载状态:未开始、下载中、已完成、失败、等待安装授权
- 下载中能看到百分比或至少看到“已下载 / 总大小”的进度文案
- 下载失败时可直接重试,不需要用户自己猜该做什么
- 如果安装未知来源权限没开,界面要明确告诉用户下一步去哪开
### 4.3 设计取舍
- 继续使用系统 `DownloadManager`
- 不实现后台自定义下载器
- 不实现增量更新,只优化整包 APK 下载体验
## 5. 设计三:导航细节细化
### 5.1 现状问题
- 根页 tab 虽然已经变成微信式,但重新进 APP 时对用户上次停留页的记忆还不稳定
- 根页返回虽然不会直接乱跳,但体验还可以更像成熟 APP
### 5.2 目标效果
- 记住用户上次停留的根 tab下次进 APP 优先回到该 tab
- 如果外部显式指定入口 tab仍然以显式入口优先
- 在根页按返回时,不再像“页面崩掉式退出”;给出更柔和的退后台反馈
### 5.3 设计取舍
- 不做复杂导航栈框架迁移
- 继续基于现有 `MainActivity` 自己维护 root tab 状态
## 6. 代码边界
这批预计新增少量纯 Java helper目的是让关键交互可以用单元测试覆盖而不是把逻辑都埋进 Activity
- `ProjectChatUiState.java`
- 负责“是否自动滚到底”“发送按钮是否可用”“临时发送中消息”的最小映射
- `OtaDownloadStateMapper.java`
- 负责把 `DownloadManager` 查询结果映射成页面文案和状态
活动页主要做接线,不堆业务判断:
- `ProjectDetailActivity.java`
- `AboutActivity.java`
- `MainActivity.java`
## 7. 验收标准
1. 聊天页发送消息后,立刻能看到发送中反馈
2. 聊天页刷新不会在用户查看历史消息时强制跳到底
3. 关于页能看到 OTA 下载状态和失败重试入口
4. 未知来源安装权限未开时,页面能给出明确引导
5. APP 再次打开时,根 tab 能恢复到上次停留位置
6. 微信式一级 UI 不被破坏,主聊天页仍然只保留 `项目目标 / 版本记录`

View File

@@ -0,0 +1,265 @@
# Boss 原生 Android 微信式 UI 回退设计
日期:`2026-03-27`
## 1. 背景
当前 `Boss` 项目已经把 Android 客户端切换到原生架构并打通了登录恢复、OTA、主 Agent、设备同步和 API 链路。但前台 UI 被改得过于偏“控制台 / 运维面板”,偏离了用户确认过的那版“微信式交互”目标。
本次工作的目标不是推翻原生 Android 路线,也不是重做后端,而是把前台体验回退到用户认可的版本:
- 保留当前原生 Android 架构
- 保留现有 API、登录恢复、OTA、设备绑定等底层能力
- 将 UI 和交互回退到“微信式”版本
## 2. 目标
本次回退后的 APP应满足以下目标
1. 打开 APP 后,首先看到的是微信式会话列表
2. 底部一级导航固定为 `会话 / 设备 / 我的`
3. 会话首页是极简聊天列表,不再以控制台卡片为主
4. 项目聊天页以消息流为主体,业务信息降到最小
5. 设备页和我的页回到微信式简单列表
6. 现有底层能力继续可用,但退出主视觉核心区域
7. 未经用户确认,不再做明显偏离微信式体验的大幅视觉发挥
## 3. 非目标
本次不做以下事情:
- 不回退到 WebView 壳路线
- 不重做服务器部署和 API 路线
- 不重写状态模型或文件存储
- 不删除 OTA、AI 账号、主 Agent、设备绑定等底层能力
- 不扩展新的业务模块
- 不继续做“控制台式”信息增强
## 4. 用户确认后的设计结论
本次设计结论来自已确认的用户输入:
- 保留现有原生 Android 架构
- 首页更接近微信聊天列表,而不是业务控制台
- 设备页与我的页都采用微信式简单列表
- 单个项目聊天页尽量像微信聊天页
- 业务入口只保留 `项目目标``版本记录`
- 线程预算、转发、handoff、风险摘要、运维面板等不再进入主 UI
## 5. 信息架构
### 5.1 一级导航
底部固定一级导航:
- `会话`
- `设备`
- `我的`
要求:
- 一级导航始终固定在底部
- 一级导航切换不丢当前 tab 的页面状态
- 不允许再次出现“滑到页面最底部才出现导航”的行为
- 一级导航视觉上接近微信的底部 tab而不是控制台式按钮组
### 5.2 二级导航
保留以下二级页,但弱化控制台味:
- 会话详情
- 项目目标
- 版本记录
- 设备详情
- 添加设备
- 账号与安全
- AI 账号
- 设置
- 技能
- 关于
下列内容不再作为主 UI 核心入口呈现:
- 线程预算
- 转发
- handoff / 调度摘要
- 风险说明
- 运维 / 审计大盘
这些能力如仍需保留,只能后移到更深层或调试入口,不得继续占据主页面主体区域。
## 6. 页面设计
### 6.1 会话首页
会话首页回退为微信式聊天列表。
每一行仅保留:
- 左侧头像
- 中间标题
- 中间最后一条消息预览
- 右侧时间
- 未读数
约束:
- 不再展示大块统计卡片、摘要卡片、控制台说明卡片
- 不在列表主行展示线程预算、风险等级、设备数、quota 等业务字段
- 主 Agent 仍置顶,但只通过位置或轻量标识体现,不做特殊面板
- 多设备项目允许保留群聊式头像组合,但排版必须仍然像聊天列表
### 6.2 项目聊天页
聊天页回退为微信式聊天页。
布局规则:
- 顶部是常规标题栏
- 聊天主体是消息流
- 输入区是主要操作区
- 只保留两个轻量入口:
- `项目目标`
- `版本记录`
这两个入口放在聊天页顶部轻量区域,不允许做成重控制条或大功能区。
明确移除出主界面的内容:
- 线程预算展示块
- handoff 状态块
- 主 Agent 调度摘要块
- 风险说明块
- 转发主入口
### 6.3 设备页
设备页回退为微信式简单列表。
每个设备条目仅保留:
- 头像
- 设备名
- 一行轻描述,例如在线状态或绑定账号
不再在列表主行展示:
- quota
- endpoint
- note
- 技能数量
- 监控式数字摘要
`添加设备` 保留,但做成普通列表入口或轻量操作入口。
### 6.4 我的页
我的页回退为微信“我”式结构。
顶部保留:
- 头像
- 昵称
- 账号信息
主菜单保留:
- `账号与安全`
- `AI 账号`
- `设置`
- `技能`
- `关于`
约束:
- `技能` 是普通菜单项,不做重卡片
- 运维 / 审计 / 修复类内容不再占据我的页主视觉
- 如果保留运维能力,必须下沉到更深层入口
## 7. 交互规则
### 7.1 返回逻辑
返回逻辑按移动端常识收口:
- 从会话详情返回到会话列表
- 从设备详情返回到设备列表
- 从我的二级页返回到我的首页
- 在一级页根节点按返回,不应直接出现异常退出体验
- 优先返回到当前 tab 根页;已经在根页时,再按移动端规则进入后台
### 7.2 状态保持
- 一级 tab 切换后应保留该 tab 已有滚动和浏览状态
- 不允许切 tab 后强制回到异常中间页
- 不允许聊天页返回后丢失会话列表位置
### 7.3 视觉约束
本次 UI 回退的视觉原则:
- 白底、浅分割、轻卡片或无卡片
- 列表优先,而不是仪表盘优先
- 信息克制
- 熟悉、直接、低学习成本
明确禁止:
- 假状态栏
- 桌面预览壳
- 大段控制台说明文案
- 监控面板式色块
- 未确认的大幅品牌化发挥
## 8. 保留不动的底层能力
以下能力继续保留,但不作为主 UI 核心呈现:
- 原生 Android 架构
- `BossApiClient` 与现有 API 路由
- 登录态与 `restore token`
- OTA 下载与安装链路
- 主 Agent 真实执行链路
- AI 账号与 API 容灾配置
- 设备绑定与本地 agent 上报
## 9. 实现边界
本次实现聚焦于 Android 原生前台体验,优先改造:
- `MainActivity`
- 会话列表和会话详情原生页
- 设备列表和设备详情首屏
- 我的首页及菜单化入口
- 底部导航、顶部栏、返回逻辑、列表单元、消息样式
本次不主动改动:
- Web 业务逻辑
- 服务端数据模型
- 部署链路
- 认证和 OTA 的底层实现
## 10. 验收标准
本次回退完成后,应满足:
1. APP 首页视觉第一印象是微信式聊天列表
2. 底部只有 `会话 / 设备 / 我的`
3. 聊天页主体是消息流,只保留 `项目目标 / 版本记录`
4. 设备页和我的页都是简单列表,而不是控制台
5. 返回逻辑符合手机用户直觉
6. 主 Agent、登录恢复、OTA、设备等底层能力仍正常可用
7. 没有再次出现未经确认的大幅 UI 风格漂移
## 11. 推荐实施顺序
建议按以下顺序实施:
1. 调整底部导航、返回逻辑和一级页骨架
2. 重做会话首页
3. 重做项目聊天页
4. 收口设备页
5. 收口我的页
6. 统一列表、按钮、间距、颜色和消息气泡样式
7. 每一批都执行编译、真机验证和文档同步

View File

@@ -0,0 +1,376 @@
# Boss 原生 Android 微信式消息转发设计
## 1. 背景
当前 `Boss` 原生 Android 客户端虽然已经恢复到微信式一级结构,但“消息转发”仍停留在过渡态:
- 原生入口还是单独的 `ProjectForwardActivity`
- 交互仍然是“选择目标项目 + 填写备注”
- 服务端接口 `POST /api/v1/projects/[projectId]/forwards` 也仍以 `targetProjectId + note` 为主
这条链路和用户要求的“微信最新逻辑”存在明显差距。用户已经明确要求:
1. 既支持单条消息转发,也支持多选消息合并转发
2. 转发流程要尽量按微信当前逻辑来
3. 单条消息转发后在目标会话里表现为普通转发消息
4. 多条消息转发后在目标会话里表现为聊天记录卡片
5. 当前一次转发先只允许选择一个目标会话
6. 转发链必须兼容现有线程会话、群聊会话和主 Agent 审批规则
因此,这次工作不是只换一个页面,而是要把“消息转发”升级成一条完整的微信式产品链路:
- 原生 Android 交互回到微信式
- 服务端账本结构能表达单条转发和聊天记录卡片
- 目标会话选择页与当前线程会话模型一致
- 群聊和审批规则能继续接入,而不是后续再重做
## 2. 目标
本次设计完成后,消息转发应满足以下目标:
1. 单条消息可从消息操作菜单直接进入转发流程。
2. 多条消息可通过多选模式进入合并转发流程。
3. 单条和多条转发共用一个微信式目标会话选择页。
4. 单次转发只允许选择一个目标会话。
5. 单条消息转发到目标会话后,显示为普通消息,但保留转发来源元数据。
6. 多条消息转发到目标会话后,显示为一张聊天记录卡片,不是多条普通消息的简单堆叠。
7. 转发目标可以是单线程会话、群聊、`主 Agent``审计对话`
8. 非开发任务状态下,如果转发行为会引发线程之间不应直接沟通的情况,后端必须能返回“需要主 Agent / 用户审批”的结果,而不是直接放行。
9. 这次改造不能破坏现有原生聊天页、会话信息页、群资料页和群聊创建链路。
## 3. 非目标
本次不做以下事项:
1. 不支持一次转发到多个目标会话。
2. 不支持转发前编辑消息内容。
3. 不支持微信收藏、逐条再编辑、转发到外部应用等额外能力。
4. 不在本次设计中完成“聊天记录卡片详情页”的完整浏览体验,只要求先把卡片消息结构和列表展示落下。
5. 不改变当前原生 Android 架构、登录恢复、群聊模型或主 Agent 主链执行方式。
## 4. 用户体验设计
### 4.1 单条消息转发
单条消息转发按微信式链路执行:
1. 用户在聊天页长按某条消息。
2. 弹出轻量消息操作菜单。
3. 菜单中点击 `转发`
4. 进入统一的 `选择一个会话` 页。
5. 用户选择一个目标会话。
6. 执行转发。
7. 返回目标会话或给出轻量成功提示。
单条转发后的展示规则:
- 在目标会话中显示为一条普通消息
- 这条消息保留 `转发` 的轻量来源标识,但整体视觉不能变成控制台卡片
- 账本结构中必须带上来源消息信息,便于后续扩展“查看原始消息”
### 4.2 多选消息合并转发
多选消息合并转发按微信式链路执行:
1. 用户在聊天页对消息执行 `多选`
2. 聊天页进入多选模式
3. 用户勾选多条消息
4. 点击底部 `转发`
5. 进入同一个 `选择一个会话`
6. 用户选择一个目标会话
7. 执行合并转发
多选转发后的展示规则:
- 在目标会话中只生成一条消息
- 该消息表现为“聊天记录卡片”
- 不能把多条消息逐条硬插入目标会话里
### 4.3 消息操作菜单
单条消息长按后的操作菜单,本次先保留以下动作:
- `转发`
- `多选`
- `复制`
- `删除`
- `取消`
规则:
1. `转发` 直接进入统一转发流程
2. `多选` 进入消息多选模式
3. 本次不再把“填写备注”作为主流程的一部分
### 4.4 多选模式
多选模式的页面行为如下:
顶部区域:
- 左侧为 `取消`
- 中间显示已选消息数量
- 不再显示普通聊天页标题和轻入口
消息区:
- 每条消息左侧出现勾选控件
- 已勾选消息有明显选中态
底部区域:
- 先只保留 `转发`
- 不在本次加入更多多选操作,避免偏离微信主链
### 4.5 目标会话选择页
单条转发和多选转发共用一个目标会话选择页,规则如下:
1. 页面标题为 `选择一个会话`
2. 页面第一屏直接显示微信式会话列表
3. 会话 cell 沿用当前首页微信式会话样式
4. 当前源会话本身不能作为目标被再次选中
5. 当前一次只能选中一个目标会话
6. 不要求用户填写备注
允许作为目标的会话类型:
- 单线程会话
- 群聊会话
- `主 Agent`
- `审计对话`
## 5. 数据模型设计
### 5.1 单条转发消息
单条消息转发后,在目标会话中仍表现为普通消息,但要补充“转发来源”元数据。
本次采用结构:
```ts
type ForwardSource = {
sourceProjectId: string;
sourceProjectName: string;
sourceThreadId?: string;
sourceThreadTitle?: string;
sourceMessageId: string;
forwardedBy: string;
forwardedAt: string;
};
```
落账本后的单条消息:
```ts
type Message = {
id: string;
kind: "text" | ...;
body: string;
forwardSource?: ForwardSource;
};
```
要求:
1. 转发后的消息仍可作为普通消息渲染
2. 必须保留来源项目、来源消息、来源线程的可追踪信息
3. 不能只把原消息正文复制过去就结束
### 5.2 多条聊天记录卡片
多条消息转发后,应写成一条新的 bundle 型消息。
本次采用结构:
```ts
type ForwardBundleItem = {
messageId: string;
senderLabel: string;
body: string;
kind: string;
sentAt: string;
};
type ForwardBundlePayload = {
sourceProjectId: string;
sourceProjectName: string;
sourceThreadId?: string;
sourceThreadTitle?: string;
itemCount: number;
startedAt: string;
endedAt: string;
items: ForwardBundleItem[];
};
```
落账本后的 bundle 消息:
```ts
type Message = {
id: string;
kind: "forward_bundle";
body: string;
forwardBundle?: ForwardBundlePayload;
};
```
要求:
1. 目标会话中只出现一张聊天记录卡片
2. 卡片中要能生成合理摘要,如消息数、来源会话、时间范围
3. bundle 的完整内容要落到账本,不能只存一个标题
## 6. 服务端接口设计
### 6.1 现有接口升级
当前已有:
- `POST /api/v1/projects/[projectId]/forwards`
这条接口应从“备注转发”升级成真正的微信式转发接口。
本次采用输入结构:
```ts
type ForwardProjectMessageInput =
| {
mode: "single";
targetProjectId: string;
sourceMessageId: string;
}
| {
mode: "bundle";
targetProjectId: string;
sourceMessageIds: string[];
};
```
当前旧字段 `note` 不再作为主语义字段,允许兼容但不再作为核心交互入口。
### 6.2 返回结构
接口返回需要至少表达:
```ts
{
ok: boolean;
message?: Message;
approvalRequired?: boolean;
approvalReason?: string;
}
```
要求:
1. 正常转发成功时返回目标会话中新生成的消息
2. 需要审批时,不直接写入目标会话,而是返回 `approvalRequired=true`
3. 失败时给出明确错误
## 7. 审批与群聊兼容设计
### 7.1 正常转发
以下情况可直接放行:
- 用户主动把消息转发到自己可见的单线程会话
- 用户主动把消息转发到群聊
- 用户主动把消息转发到 `主 Agent`
- 用户主动把消息转发到 `审计对话`
### 7.2 需要审批的场景
如果这次转发在业务语义上会触发:
- 非开发任务状态下的线程直接互相沟通
那么后端必须先命中治理规则:
1. 不直接放行
2. 返回 `approvalRequired`
3. 由主 Agent 再向用户请求批准
这次即使还不把完整审批 UI 全做完,也必须在接口和消息层预留这条分支。
### 7.3 和群聊的关系
转发目标页对群聊和单线程会话一视同仁,目标本质就是会话。
要求:
1. 群聊和单线程会话共用同一套目标选择页
2. 不能因为群聊存在,就做另一套“转群聊”专用流程
3. 后端只在治理规则阶段区分是否需要审批,不在选择页阶段区分
## 8. Android 原生页面设计
### 8.1 ProjectDetailActivity
需要补以下交互:
1. 单条消息长按弹出操作菜单
2. 进入多选模式
3. 多选模式顶部与底部状态切换
4. `转发` 入口跳转到统一会话选择页
### 8.2 新增原生活动页
本次新增:
- `ForwardTargetActivity`
- 统一目标会话选择页
- 同时服务单条转发和多选转发
`ProjectForwardActivity` 不再承担主转发链路,而是下沉为兼容入口;如果旧入口仍被触发,只负责立即跳转到新的 `ForwardTargetActivity`
### 8.3 转发后的返回行为
要求:
1. 转发成功后给出轻量反馈
2. 返回链符合手机直觉
3. 不能出现完成后回退错层、丢当前页状态、或直接退桌面
## 9. 测试与验收标准
### 9.1 单条转发验收
1. 长按某条消息,能看到消息菜单
2.`转发` 后进入目标会话选择页
3. 选择一个会话后,成功写入目标会话
4. 目标会话里显示普通转发消息
5. 服务端账本中能看到 `forwardSource`
### 9.2 多条转发验收
1. 进入多选模式并勾选多条消息
2. 点击底部 `转发`
3. 进入同一个目标会话选择页
4. 选择一个会话后,成功写入目标会话
5. 目标会话中只出现一张聊天记录卡片
6. 服务端账本中能看到 `forwardBundle`
### 9.3 目标选择页验收
1. 会话项样式和首页一致
2. 一次只能选中一个目标会话
3. 源会话本身不能被选中
4. 单线程、群聊、主 Agent、审计对话都能正常显示
### 9.4 审批兼容验收
1. 开发任务场景下,转发能直接通过
2. 命中非开发任务治理规则时,接口返回 `approvalRequired`
3. 命中审批规则时不会把消息错误地直接写进目标会话
### 9.5 本轮实现完成标准
本轮可以视为完成,当且仅当:
1. 原生 Android 已支持单条转发
2. 原生 Android 已支持多选合并转发
3. 目标会话选择页已经替换当前“备注转发页”
4. 服务端消息结构已经支持 `forwardSource``forwardBundle`
5. 转发接口已经支持 `single / bundle`
6. 审批闸口已经在接口层和账本层预留

View File

@@ -0,0 +1,386 @@
# Boss 原生 Android 旧版 UI 全量还原与线程群聊设计
## 1. 背景
当前 `Boss` 原生 Android 客户端虽然已经回退到微信式一级结构,但页面视觉、主入口取舍、线程展示方式和会话模型仍然没有完全回到用户确认过的旧版 UI。
本次工作的目标不是回滚底层实现,也不是退回 WebView而是在保留现有原生 Android、现有 API、现有登录恢复、OTA、设备绑定和主 Agent 执行链路的前提下,把前台页面和交互完整拉回旧版 UI 语言,并补齐新的线程会话与群聊规则。
本次以 `design/exports/ui-codex-ops-mobile-v13/` 为唯一主视觉基准。图中出现的页面要求严格 1:1 还原;图中未出现但业务必须存在的页面,也必须延续同一套微信式轻界面、列表结构、按钮层级和信息密度,不能继续保留控制台式重面板风格。
## 2. 目标
本次改造完成后APP 应满足以下目标:
1. 一级导航固定为 `会话 / 设备 / 我的`,整体视觉回到旧版导出图的白底、浅灰卡片、轻绿色强调风格。
2. 所有会话都按聊天工具模型理解,而不是项目卡片模型。
3. APP 中每一个聊天窗口,对应某个设备、某个 Codex 文件夹里的一个线程;同一文件夹下多个线程,在 APP 中显示为多个独立聊天窗口。
4. 会话列表主标题显示线程名;副信息指定位置显示线程所属文件夹名。
5. 会话列表每一项右下角增加动态小图标,只表达“当前后台使用数量”,不展示文字,不展示名称。
6. 线程名支持在 APP 内按微信最新逻辑修改,并同步回对应 Codex 线程。
7. 支持从聊天页右上角发起群聊,把不同设备里的不同线程拉进同一个独立群聊。
8. 群聊由主 Agent 发起和监督;开发任务状态下允许线程直接沟通,非开发任务状态下线程对话必须先经主 Agent 请求用户批准。
9. `主 Agent``审计对话` 在会话首页固定置顶,但视觉上仍保持普通会话样式,只用顺序和轻量置顶标识区分。
10. 图中未出现但必须保留的页面,例如 `AI 账号 / 技能 / 运维与修复 / 审计对话`,也要落到同一套微信式轻界面,不允许继续长成控制台风格。
## 3. 非目标
本次不做以下事项:
1. 不回退到 WebView 壳路线。
2. 不回退当前原生 Android 架构、登录恢复、OTA、设备绑定和主 Agent 执行链路。
3. 不删除后端已有的线程预算、转发、运维、审计、AI 账号、Skill 等能力;只调整它们在前台的入口、视觉和层级。
4. 不在本次设计里细化主 Agent 的最终执行策略,只先定义群聊的发起、监督和审批框架。
5. 不恢复假的 `9:41 / 5G / 电量` 状态栏;状态栏由真机系统自己显示。
## 4. 视觉基准
### 4.1 主视觉来源
唯一主视觉来源:
- `design/exports/ui-codex-ops-mobile-v13/`
核心对照图:
- 会话首页:`g8Qpr.png`
- 聊天页:`grcep.png`
- 设备页:`5iGU7.png`
- 我的页:`LQOJ0.png`
- 登录页:`i7IZ1.png`
- 总览图:`d5gpt.png`
### 4.2 视觉原则
1. 以白底、浅灰面、圆角列表卡片、轻绿色主按钮为主。
2. 页面主体必须是内容本身,不允许再堆重统计卡片、监控面板或控制台摘要。
3. 顶部栏、列表、聊天输入区、轻按钮和 tab 结构必须尽量贴近旧版导出图。
4. 不恢复假的系统状态栏,也不恢复桌面预览卡片壳。
5. 图里存在的布局、间距、对齐、轻重层级,应优先向导出图靠拢,而不是向当前实现妥协。
## 5. 页面信息架构
### 5.1 一级导航
一级导航固定为:
1. `会话`
2. `设备`
3. `我的`
底部 tab 常驻,不滚动隐藏,不做额外复杂入口。
### 5.2 主 UI 保留页面
主 UI 需要保留并统一到旧版视觉语言的页面包括:
- 登录
- 注册
- 忘记密码
- 会话首页
- 单线程聊天页
- 群聊页
- 项目目标
- 版本迭代记录
- 消息转发
- 设备首页
- 添加设备
- 我的首页
- 账号与安全
- 设置
- 关于
- 运维与修复
- 审计对话
- AI 账号
- 技能
### 5.3 主 UI 撤出的内容
以下内容不删除后端能力,但不再以控制台方式露出:
- 线程预算大面板
- handoff / 风险摘要大块说明
- 配额和监控式状态卡
- 审计、运维、转发、线程详情的大块控制台入口
它们若仍需存在,只能以轻量页面、列表项、二级入口或会话方式出现。
## 6. 会话模型
### 6.1 单线程会话
定义:
- 一个聊天窗口对应一个线程。
- 一个线程隶属于某个设备上的某个 Codex 文件夹。
- 同一文件夹下多个线程,在 APP 中显示为多个独立聊天窗口。
列表字段:
- 主标题:线程名
- 副信息指定行:文件夹名
- 预览行:最后一条消息
- 右上:最后一次对话时间
- 右下:动态小图标,表示当前后台使用数量
### 6.2 特殊会话
特殊会话包括:
- `主 Agent`
- `审计对话`
规则:
1. 固定置顶在会话首页顶部。
2. 视觉上与普通会话尽量一致。
3. 只通过顺序和轻量置顶标识区分。
4. 不做特殊大卡片。
### 6.3 群聊会话
定义:
- 群聊是独立会话,不覆盖或替换原来的单线程会话。
- 群成员可以来自不同设备、不同文件夹、不同线程。
- 群头像按微信逻辑显示多头像组合。
命名规则:
1. 创建群聊时自动生成默认群名。
2. 创建完成后允许修改群名。
## 7. 会话列表设计
会话列表按旧版导出图风格统一,核心规则如下:
1. 页面第一屏就是纯会话列表。
2. `主 Agent``审计对话` 位于最上方。
3. 普通会话按聊天工具列表排列,不再掺杂控制台说明。
4. 每个会话 item 固定包含:
- 左侧头像
- 主标题:线程名或群名
- 指定副信息行:文件夹名
- 最后一条消息预览
- 最后一次对话时间
- 右下动态小图标,表示后台使用数量
5. 不再在列表主行显示线程预算、设备配额、风险摘要等重字段。
### 7.1 文件夹名显示位置
用户指定的文件夹名显示位置是聊天列表 item 中主标题下方的副信息行,位于最后一条消息预览之上或其同一信息层级位置,必须与用户给出的截图位置一致。
### 7.2 后台数量图标
规则如下:
1. 只显示动态小图标,不显示数字文本,不显示说明文本。
2. 图标位置在当前最后一次对话时间下方的右下区域。
3. 图标表达的是当前线程后台使用数量。
4. 不在列表里展示“后台窗口名”或“后台窗口描述”。
## 8. 聊天页设计
### 8.1 单线程聊天页
单线程聊天页按微信式聊天页构成:
1. 顶部栏:
- 左返回
- 中间标题:线程名
- 右上角入口
2. 顶部轻入口:
- `项目目标`
- `版本迭代记录`
3. 中部主体:
- 纯消息流
4. 底部输入区:
- 文本输入为主
- 图片 / 视频 / 转发作为轻入口
不允许在主聊天页继续出现:
- 运维大面板
- 线程预算块
- handoff 摘要块
- 控制台式状态卡
### 8.2 群聊页
群聊页整体结构与单线程聊天页保持一致,但:
1. 标题显示群名。
2. 头像显示群头像组合。
3. 右上角进入群资料页。
4. 群成员消息需区分来源线程。
5. 主 Agent 在群里承担发起和监督角色。
## 9. 改名逻辑
改名行为必须遵循微信最新逻辑,而不是长按改名、左滑改名等旧交互。
### 9.1 线程改名
流程:
1. 进入单线程聊天页。
2. 点击右上角进入会话信息页。
3. 在会话信息页中修改线程名。
4. 保存后同步更新:
- APP 本地显示
- 服务端状态账本
- 对应 Codex 线程名称
### 9.2 群聊改名
流程:
1. 进入群聊页。
2. 点击右上角进入群聊信息页。
3. 在群聊信息页中修改群名。
4. 保存后更新群聊会话显示。
## 10. 群聊创建与沟通规则
### 10.1 发起方式
群聊创建从聊天页右上角 `+` 发起:
1. 从单线程聊天页点击右上角 `+`
2. 选择 `发起群聊`
3. 进入线程选择页
4. 选择任意设备里的任意线程
5. 自动创建一个新的独立群聊
6. 自动生成默认群名
7. 创建成功后进入新群聊页
### 10.2 群聊会话与原会话关系
规则:
1. 新建群聊后,原来的单线程会话保留。
2. 新群聊作为独立聊天窗口出现在会话列表。
3. 群聊不会覆盖原线程会话。
### 10.3 群聊治理规则
群聊中的线程协作遵循以下规则:
1. 开发任务过程中:
- 允许不同线程直接在群聊内对话和协作。
2. 非开发任务状态下:
- 线程之间不能直接自由对话。
- 如需对话,必须先请求主 Agent。
- 主 Agent 再向用户请求是否批准两个线程继续沟通。
- 用户批准后,线程之间才可继续对话。
### 10.4 主 Agent 角色
主 Agent 在群聊中的角色是:
1. 群聊发起者
2. 群聊监督者
3. 线程间对话的规则执行者
本次先落会话结构、入口和规则,不在本次设计内细化主 Agent 的最终执行策略。
## 11. 深层页补齐策略
导出图未直接覆盖的页面,也必须统一到这套 UI 语言:
- AI 账号
- 技能
- 设置
- 账号与安全
- 运维与修复
- 审计对话
- 线程信息 / 会话信息 / 群聊信息
- 群聊成员选择页
补齐原则:
1. 统一白底、浅卡片、轻按钮风格。
2. 统一列表式、表单式和信息页结构。
3. 不再使用控制台式高密度状态块。
4. 即使信息复杂,也优先拆成列表项或二级页,而不是堆在一个页面里。
## 12. 返回逻辑与导航逻辑
### 12.1 一级导航
底部 tab 固定显示:
- 会话
- 设备
- 我的
### 12.2 返回规则
规则如下:
1. 从二级页返回,回到上一页。
2. 从聊天页返回,回到会话列表。
3. 从设备详情返回,回到设备列表。
4. 从我的二级页返回,回到我的首页。
5. 在根级 tab 页按返回时,先回 `会话` tab。
6. 已在 `会话` 根页时,再按一次返回进入后台。
## 13. 数据与同步要求
### 13.1 线程与会话映射
必须建立清晰映射:
- 设备
- 文件夹
- 线程
- 会话窗口
要求:
1. 一个线程映射一个单线程会话。
2. 一个文件夹下多个线程映射多个独立会话。
3. 会话列表必须能拿到线程名、文件夹名、最后消息、最后时间、后台使用数量。
### 13.2 改名同步
线程改名必须同时影响:
1. APP 会话标题
2. 服务端状态
3. 对应 Codex 线程名称
### 13.3 群聊同步
群聊需要持久化:
1. 群聊 ID
2. 群名
3. 群成员线程列表
4. 群创建者
5. 群内规则状态
6. 是否为开发任务状态
## 14. 验收标准
本次工作完成后,必须满足以下验收条件:
1. 图中已有页面可与导出图逐页对照,达到 1:1 视觉还原级别。
2. 图中未出现但必须保留的页面,也统一成同一套微信式轻界面。
3. 会话列表主标题显示线程名,指定副信息位显示文件夹名。
4. 每个会话右下角都显示动态后台数量图标。
5. `主 Agent``审计对话` 置顶,但视觉仍为普通会话。
6. 聊天页右上角遵循微信最新逻辑,支持进入信息页改名。
7. 线程改名后Codex 对应线程也同步改名。
8. 支持从聊天页右上角 `+` 发起群聊。
9. 群聊为独立会话,保留原单线程会话。
10. 群聊支持自动命名、后续改名、群头像组合显示。
11. 开发任务状态与非开发任务状态下的线程沟通规则已落地。
12. 主聊天面、设备面、我的页不再出现控制台式大面板。
## 15. 风险与约束
1. 导出图是主视觉基准,但不是全部页面的完整交互稿,因此深层页需要在同一套风格内补齐,而不是照当前实现继续延展。
2. 线程名同步到 Codex 线程需要可靠的线程标识和回写能力,否则会产生 APP 名称与 Codex 名称不一致的问题。
3. 群聊引入后,单线程会话、群聊会话和主 Agent / 审计对话会并存,需要明确排序、置顶和未读规则。
4. 主 Agent 对线程间对话的审批与监督规则本次先定义边界,不在本次设计中进一步算法化。

View File

@@ -1,11 +1,11 @@
{
"artifactType": "aab",
"fileName": "boss-android-v2.1.1-release.aab",
"urlPath": "/downloads/boss-android-v2.1.1-release.aab",
"sizeBytes": 2853490,
"updatedAt": "2026-03-26T15:51:15Z",
"sha256": "c67341ca50d219e3d75baa6c88520c11d475611bed33237710d4518f292779c9",
"versionName": "2.1.1",
"versionCode": 8,
"fileName": "boss-android-v2.4.0-release.aab",
"urlPath": "/downloads/boss-android-v2.4.0-release.aab",
"sizeBytes": 2894817,
"updatedAt": "2026-03-28T00:59:18Z",
"sha256": "beaca830a470bb5af180cb75dff60fc9e2039f10214480ae0cd7503bd793af22",
"versionName": "2.4.0",
"versionCode": 12,
"buildFlavor": "release"
}

View File

@@ -1,10 +1,10 @@
{
"fileName": "boss-android-v2.1.1-release.apk",
"fileName": "boss-android-v2.4.0-release.apk",
"urlPath": "/api/v1/user/ota/package",
"sizeBytes": 3032808,
"updatedAt": "2026-03-26T15:51:10Z",
"sha256": "453412d605ad2cd3b1cabf806752d1288a8b23f1c61900b007468a264dda3459",
"versionName": "2.1.1",
"versionCode": 8,
"sizeBytes": 3071649,
"updatedAt": "2026-03-28T00:59:14Z",
"sha256": "34831f13e8458fe668f7358c6a0d37d39430d3b8002a6c998b445b40f99e3672",
"versionName": "2.4.0",
"versionCode": 12,
"buildFlavor": "release"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,6 +2,18 @@ import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { forwardProjectMessage } from "@/lib/boss-data";
type ForwardBody =
| {
mode?: "single";
targetProjectId?: string;
sourceMessageId?: string;
}
| {
mode?: "bundle";
targetProjectId?: string;
sourceMessageIds?: string[];
};
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
@@ -11,25 +23,60 @@ export async function POST(
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
const body = (await request.json()) as {
targetProjectId?: string;
note?: string;
};
const body = (await request.json()) as ForwardBody;
if (!body.targetProjectId || !body.note) {
const mode = body.mode ?? "single";
const targetProjectId = body.targetProjectId;
const sourceMessageId: string | undefined =
"sourceMessageId" in body ? body.sourceMessageId : undefined;
const sourceMessageIds: string[] =
"sourceMessageIds" in body && Array.isArray(body.sourceMessageIds)
? body.sourceMessageIds
: [];
if (!targetProjectId) {
return NextResponse.json(
{ ok: false, message: "缺少 targetProjectId 或 note" },
{ ok: false, message: "缺少 targetProjectId" },
{ status: 400 },
);
}
if (mode === "bundle") {
if (sourceMessageIds.length <= 1) {
return NextResponse.json(
{ ok: false, message: "bundle 转发至少需要 2 条 sourceMessageIds" },
{ status: 400 },
);
}
} else if (!sourceMessageId) {
return NextResponse.json(
{ ok: false, message: "single 转发缺少 sourceMessageId" },
{ status: 400 },
);
}
try {
const message = await forwardProjectMessage({
sourceProjectId: projectId,
targetProjectId: body.targetProjectId,
note: body.note,
const result =
mode === "bundle"
? await forwardProjectMessage({
sourceProjectId: projectId,
mode: "bundle",
targetProjectId,
sourceMessageIds,
requestedBy: session.account,
})
: await forwardProjectMessage({
sourceProjectId: projectId,
mode: "single",
targetProjectId,
sourceMessageId: sourceMessageId ?? "",
requestedBy: session.account,
});
return NextResponse.json({
ok: true,
message: result.message ?? null,
approvalRequired: Boolean(result.approvalRequired),
approvalReason: result.approvalReason ?? null,
});
return NextResponse.json({ ok: true, message });
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { createProjectGroupChat } from "@/lib/boss-data";
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
const body = (await request.json()) as {
memberProjectIds?: string[];
};
try {
const project = await createProjectGroupChat({
sourceProjectId: projectId,
memberProjectIds: Array.isArray(body.memberProjectIds)
? body.memberProjectIds.filter((memberProjectId) => typeof memberProjectId === "string")
: [],
createdBy: session.account,
});
return NextResponse.json({ ok: true, project });
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: 400 },
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { appendProjectMessage } from "@/lib/boss-data";
import { appendProjectMessage, readState } from "@/lib/boss-data";
import { replyToMasterAgentUserMessage } from "@/lib/boss-master-agent";
export async function POST(
@@ -38,7 +38,24 @@ export async function POST(
});
}
return NextResponse.json({ ok: true, message, masterReply });
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
const collaborationGate = project
? {
isGroup: project.isGroup,
collaborationMode: project.collaborationMode,
requiresMasterAgentApproval:
project.isGroup && project.collaborationMode === "approval_required",
approvalState: project.approvalState,
}
: {
isGroup: false,
collaborationMode: "development" as const,
requiresMasterAgentApproval: false,
approvalState: "not_required" as const,
};
return NextResponse.json({ ok: true, message, masterReply, collaborationGate });
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { readState } from "@/lib/boss-data";
type ConversationParticipant = {
projectId: string;
deviceId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
avatar: string;
isSourceProject: boolean;
};
function getFallbackAvatar(label: string) {
const trimmed = label.trim();
if (!trimmed) return "A";
return trimmed.slice(0, 1).toUpperCase();
}
function buildParticipant(
projectId: string,
deviceId: string,
threadId: string,
threadDisplayName: string,
folderName: string,
avatar?: string,
isSourceProject = false,
): ConversationParticipant {
return {
projectId,
deviceId,
threadId,
threadDisplayName,
folderName,
avatar: avatar?.trim() || getFallbackAvatar(threadDisplayName),
isSourceProject,
};
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
const participants = project.isGroup
? (project.groupMembers.length > 0
? project.groupMembers.map((member) => {
const device = state.devices.find((item) => item.id === member.deviceId);
return buildParticipant(
member.projectId,
member.deviceId,
member.threadId,
member.threadDisplayName,
member.folderName,
device?.avatar,
member.projectId === project.id,
);
})
: [
buildParticipant(
project.id,
project.deviceIds[0] ?? project.id,
project.threadMeta.threadId,
project.threadMeta.threadDisplayName,
project.threadMeta.folderName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
true,
),
])
: [
buildParticipant(
project.id,
project.deviceIds[0] ?? project.id,
project.threadMeta.threadId,
project.threadMeta.threadDisplayName,
project.threadMeta.folderName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
true,
),
];
return NextResponse.json({
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
});
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { renameGroupChat, renameProjectThread } from "@/lib/boss-data";
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
const body = (await request.json()) as {
mode?: "thread" | "group";
name?: string;
};
const name = body.name?.trim();
if (!name) {
return NextResponse.json({ ok: false, message: "EMPTY_NAME" }, { status: 400 });
}
try {
const project =
body.mode === "group"
? await renameGroupChat({
projectId,
name,
requestedBy: session.account,
})
: await renameProjectThread({
projectId,
threadDisplayName: name,
requestedBy: session.account,
});
return NextResponse.json({ ok: true, project });
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: 400 },
);
}
}

View File

@@ -11,12 +11,45 @@ export type DeviceStatus = "online" | "abnormal" | "offline";
export type DeviceSource = "production" | "demo";
export type GoalState = "pending" | "completed";
export type MessageSender = "master" | "device" | "user" | "ops" | "audit";
// Forwarding uses a structured contract so the route can distinguish
// single-message forwarding, bundle forwarding, and the legacy notice shape.
export type MessageKind =
| "text"
| "voice_intent"
| "image_intent"
| "video_intent"
| "forward_notice";
| "forward_notice"
| "forward_single"
| "forward_bundle";
export interface ForwardSource {
sourceProjectId: string;
sourceProjectName: string;
sourceThreadId?: string;
sourceThreadTitle?: string;
sourceMessageId: string;
forwardedBy: string;
forwardedAt: string;
}
export interface ForwardBundleItem {
messageId: string;
senderLabel: string;
body: string;
kind: string;
sentAt: string;
}
export interface ForwardBundlePayload {
sourceProjectId: string;
sourceProjectName: string;
sourceThreadId?: string;
sourceThreadTitle?: string;
itemCount: number;
startedAt: string;
endedAt: string;
items: ForwardBundleItem[];
}
export type ContextBudgetLevel = "safe" | "watch" | "urgent" | "critical";
export type ThreadState =
| "idle"
@@ -113,6 +146,8 @@ export interface Message {
body: string;
sentAt: string;
kind?: MessageKind;
forwardSource?: ForwardSource;
forwardBundle?: ForwardBundlePayload;
}
export interface GoalItem {
@@ -130,6 +165,25 @@ export interface VersionEntry {
createdAt: string;
}
export interface ThreadConversationMeta {
projectId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
activityIconCount: number;
updatedAt: string;
codexThreadRef?: string;
codexFolderRef?: string;
}
export interface GroupConversationMember {
projectId: string;
deviceId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
}
export interface Project {
id: string;
name: string;
@@ -140,6 +194,11 @@ export interface Project {
updatedAt: string;
lastMessageAt: string;
isGroup: boolean;
threadMeta: ThreadConversationMeta;
groupMembers: GroupConversationMember[];
createdByAgent: boolean;
collaborationMode: "development" | "approval_required";
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
unreadCount: number;
riskLevel: RiskLevel;
contextBudgetPct?: number;
@@ -698,6 +757,20 @@ const initialState: BossState = {
updatedAt: "2026-03-25T12:06:00+08:00",
lastMessageAt: "2026-03-25T12:06:00+08:00",
isGroup: false,
threadMeta: {
projectId: "master-agent",
threadId: "thread-master-main",
threadDisplayName: "主 Agent 汇总",
folderName: "主控线程",
activityIconCount: 1,
updatedAt: "2026-03-25T12:06:00+08:00",
codexThreadRef: "thread-master-main",
codexFolderRef: "master-agent",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 0,
riskLevel: "medium",
contextBudgetPct: 71,
@@ -724,6 +797,20 @@ const initialState: BossState = {
updatedAt: "2026-03-25T11:52:00+08:00",
lastMessageAt: "2026-03-25T11:52:00+08:00",
isGroup: false,
threadMeta: {
projectId: "boss-console",
threadId: "thread-boss-ui",
threadDisplayName: "北区试产线回归",
folderName: "归档确认",
activityIconCount: 1,
updatedAt: "2026-03-25T11:52:00+08:00",
codexThreadRef: "thread-boss-ui",
codexFolderRef: "boss-console",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 2,
riskLevel: "medium",
contextBudgetPct: 62,
@@ -795,6 +882,35 @@ const initialState: BossState = {
updatedAt: "2026-03-25T10:58:00+08:00",
lastMessageAt: "2026-03-25T10:58:00+08:00",
isGroup: true,
threadMeta: {
projectId: "audit-collab",
threadId: "thread-audit-chief",
threadDisplayName: "审计对话",
folderName: "审计群聊",
activityIconCount: 2,
updatedAt: "2026-03-25T10:58:00+08:00",
codexThreadRef: "thread-audit-chief",
codexFolderRef: "audit-collab",
},
groupMembers: [
{
projectId: "audit-collab",
deviceId: "mac-studio",
threadId: "thread-audit-chief",
threadDisplayName: "审计对话",
folderName: "审计群聊",
},
{
projectId: "audit-collab",
deviceId: "win-gpu-01",
threadId: "thread-audit-hardware",
threadDisplayName: "Windows 摄像头证据",
folderName: "审计群聊",
},
],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 1,
riskLevel: "high",
messages: [
@@ -1267,6 +1383,140 @@ function nowIso() {
return new Date().toISOString();
}
function normalizeThreadMeta(
raw: Partial<ThreadConversationMeta> | undefined,
project: { id: string; name: string; isGroup: boolean; updatedAt: string },
fallback?: ThreadConversationMeta,
): ThreadConversationMeta {
return {
projectId: raw?.projectId ?? project.id,
threadId: raw?.threadId ?? `thread-${project.id}`,
threadDisplayName: raw?.threadDisplayName ?? project.name,
folderName: raw?.folderName ?? fallback?.folderName ?? (project.isGroup ? "群聊" : project.name),
activityIconCount: Math.max(1, raw?.activityIconCount ?? fallback?.activityIconCount ?? (project.isGroup ? 2 : 1)),
updatedAt: raw?.updatedAt ?? project.updatedAt ?? nowIso(),
codexThreadRef: raw?.codexThreadRef,
codexFolderRef: raw?.codexFolderRef,
};
}
function normalizeGroupMember(
raw: Partial<GroupConversationMember>,
fallbackProjectId: string,
fallbackThreadMeta: ThreadConversationMeta,
): GroupConversationMember {
return {
projectId: raw.projectId ?? fallbackProjectId,
deviceId: raw.deviceId ?? "",
threadId: raw.threadId ?? fallbackThreadMeta.threadId,
threadDisplayName: raw.threadDisplayName ?? fallbackThreadMeta.threadDisplayName,
folderName: raw.folderName ?? fallbackThreadMeta.folderName,
};
}
function dedupeStrings(values: string[]) {
return [...new Set(values.filter((value) => Boolean(value)))];
}
function dedupeGroupMembers(members: GroupConversationMember[]) {
const seen = new Set<string>();
const deduped: GroupConversationMember[] = [];
for (const member of members) {
const key = `${member.projectId}:${member.deviceId}:${member.threadId}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(member);
}
return deduped;
}
function buildLegacyGroupMembers(
projectId: string,
deviceIds: string[],
threadMeta: ThreadConversationMeta,
) {
return dedupeStrings(deviceIds).map((deviceId, index) => ({
projectId,
deviceId,
threadId:
index === 0 ? threadMeta.threadId : `${threadMeta.threadId}:${slugify(deviceId)}`,
threadDisplayName: threadMeta.threadDisplayName,
folderName: threadMeta.folderName,
}));
}
function normalizeProjectConversationShape(
project: Project,
options?: {
allowedDeviceIds?: Set<string>;
},
) {
const allowedDeviceIds = options?.allowedDeviceIds;
const normalizedThreadMeta = {
...project.threadMeta,
projectId: project.id,
};
const normalizedExplicitMembers = dedupeGroupMembers(
project.groupMembers.map((member) =>
normalizeGroupMember(member, project.id, normalizedThreadMeta),
),
);
const hasExplicitGroupMembers = normalizedExplicitMembers.length > 0;
const legacyGroupRequested = !hasExplicitGroupMembers && project.isGroup;
const resolvedGroupMembers = hasExplicitGroupMembers
? normalizedExplicitMembers
: legacyGroupRequested
? buildLegacyGroupMembers(project.id, project.deviceIds, normalizedThreadMeta)
: [];
const filteredGroupMembers = allowedDeviceIds
? resolvedGroupMembers.filter((member) => allowedDeviceIds.has(member.deviceId))
: resolvedGroupMembers;
if (filteredGroupMembers.length > 0) {
project.isGroup = true;
project.groupMembers = dedupeGroupMembers(filteredGroupMembers);
project.deviceIds = dedupeStrings(project.groupMembers.map((member) => member.deviceId));
project.threadMeta = {
...normalizedThreadMeta,
activityIconCount: Math.max(1, project.groupMembers.length),
};
return project;
}
project.isGroup = false;
project.groupMembers = [];
project.deviceIds = allowedDeviceIds
? project.deviceIds.filter((deviceId) => allowedDeviceIds.has(deviceId))
: project.deviceIds;
project.threadMeta = {
...normalizedThreadMeta,
activityIconCount: Math.max(1, normalizedThreadMeta.activityIconCount ?? 1),
};
return project;
}
function resolveProjectUpdatedAt(project: Pick<Project, "updatedAt" | "lastMessageAt" | "threadMeta">, latestActivityAt?: string) {
return latestIsoTimestamp(
project.updatedAt,
project.lastMessageAt,
project.threadMeta.updatedAt,
latestActivityAt,
);
}
function latestIsoTimestamp(...values: Array<string | undefined>) {
let latestValue: string | undefined;
let latestTime = 0;
for (const value of values) {
const valueTime = messageTimeValue(value);
if (valueTime > latestTime) {
latestTime = valueTime;
latestValue = value;
}
}
return latestValue ?? nowIso();
}
function ensureArray<T>(value: T[] | undefined, fallback: T[]) {
return Array.isArray(value) ? value : fallback;
}
@@ -1592,12 +1842,24 @@ function normalizeMessage(raw: Partial<Message>): Message {
body: raw.body ?? "",
sentAt: raw.sentAt ?? nowIso(),
kind: raw.kind ?? "text",
forwardSource: raw.forwardSource,
forwardBundle: raw.forwardBundle,
};
}
function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
const base = fallback ?? cloneInitialState().projects[0];
return {
const projectId = raw.id ?? base.id;
const projectName = raw.name ?? base.name;
const projectUpdatedAt = latestIsoTimestamp(raw.updatedAt, raw.lastMessageAt, base.updatedAt, base.lastMessageAt);
const threadMetaFallback = fallback?.id === projectId ? fallback.threadMeta : undefined;
const threadMeta = normalizeThreadMeta(raw.threadMeta, {
id: projectId,
name: projectName,
isGroup: raw.isGroup ?? base.isGroup ?? false,
updatedAt: projectUpdatedAt,
}, threadMetaFallback);
const project: Project = {
...base,
...raw,
pinned: raw.pinned ?? base.pinned,
@@ -1620,7 +1882,17 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
summary: version.summary ?? "",
createdAt: version.createdAt ?? nowIso(),
})),
threadMeta,
createdByAgent: raw.createdByAgent ?? false,
collaborationMode: raw.collaborationMode ?? "development",
approvalState: raw.approvalState ?? "not_required",
};
project.groupMembers = ensureArray(raw.groupMembers, []).map((member) =>
normalizeGroupMember(member, projectId, project.threadMeta),
);
normalizeProjectConversationShape(project);
project.updatedAt = resolveProjectUpdatedAt(project, project.threadMeta.updatedAt);
return project;
}
function normalizeState(raw: Partial<BossState> | undefined): BossState {
@@ -2253,11 +2525,11 @@ function syncDerivedState(input: BossState) {
for (const project of state.projects) {
project.deviceIds = project.deviceIds.filter((deviceId) => visibleDeviceIds.has(deviceId));
project.isGroup = project.deviceIds.length > 1;
const projectSnapshots = state.threadContextSnapshots
.filter((snapshot) => snapshot.projectId === project.id)
.sort(compareSnapshotsForRisk);
normalizeProjectConversationShape(project, { allowedDeviceIds: visibleDeviceIds });
project.riskLevel = deriveRiskFromSnapshots(projectSnapshots);
if (project.isGroup) {
project.contextBudgetPct = undefined;
@@ -2271,7 +2543,7 @@ function syncDerivedState(input: BossState) {
}
project.lastMessageAt = latestProjectTimestamp(state, project.id);
project.updatedAt = project.lastMessageAt;
project.updatedAt = resolveProjectUpdatedAt(project, project.lastMessageAt);
project.preview = deriveProjectPreview(state, project);
project.unreadCount = Math.max(0, project.unreadCount ?? 0);
}
@@ -3826,6 +4098,150 @@ export async function updateConversationAction(
return project;
}
export async function renameProjectThread(input: {
projectId: string;
threadDisplayName: string;
requestedBy: string;
}) {
const threadDisplayName = input.threadDisplayName.trim();
if (!threadDisplayName) {
throw new Error("THREAD_DISPLAY_NAME_REQUIRED");
}
const project = await mutateState((state) => {
const nextProject = state.projects.find((item) => item.id === input.projectId);
if (!nextProject) throw new Error("PROJECT_NOT_FOUND");
if (nextProject.isGroup) throw new Error("PROJECT_IS_GROUP_CHAT");
const updatedAt = nowIso();
nextProject.name = threadDisplayName;
nextProject.threadMeta.threadDisplayName = threadDisplayName;
nextProject.threadMeta.updatedAt = updatedAt;
nextProject.updatedAt = updatedAt;
return nextProject;
});
publishBossEvent("conversation.updated", {
projectId: input.projectId,
note: `renamed by ${input.requestedBy}`,
});
return project;
}
export async function renameGroupChat(input: {
projectId: string;
name: string;
requestedBy: string;
}) {
const name = input.name.trim();
if (!name) {
throw new Error("GROUP_CHAT_NAME_REQUIRED");
}
const project = await mutateState((state) => {
const nextProject = state.projects.find((item) => item.id === input.projectId);
if (!nextProject) throw new Error("PROJECT_NOT_FOUND");
if (!nextProject.isGroup) throw new Error("PROJECT_NOT_GROUP_CHAT");
const updatedAt = nowIso();
nextProject.name = name;
nextProject.threadMeta.threadDisplayName = name;
nextProject.threadMeta.updatedAt = updatedAt;
nextProject.updatedAt = updatedAt;
return nextProject;
});
publishBossEvent("conversation.updated", {
projectId: input.projectId,
note: `renamed by ${input.requestedBy}`,
});
return project;
}
export async function createProjectGroupChat(input: {
sourceProjectId: string;
memberProjectIds: string[];
createdBy: string;
}) {
const project = await mutateState((state) => {
const source = state.projects.find((item) => item.id === input.sourceProjectId);
if (!source) throw new Error("GROUP_CHAT_SOURCE_NOT_FOUND");
const requestedProjectIds = [input.sourceProjectId, ...input.memberProjectIds];
const memberProjects: Project[] = [];
const seenProjectIds = new Set<string>();
for (const projectId of requestedProjectIds) {
if (seenProjectIds.has(projectId)) {
continue;
}
seenProjectIds.add(projectId);
const memberProject = state.projects.find((item) => item.id === projectId);
if (!memberProject) {
throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND");
}
memberProjects.push(memberProject);
}
if (memberProjects.length < 2) {
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
}
const now = nowIso();
const projectId = randomToken("project");
const threadId = randomToken("thread");
const threadDisplayName = source.threadMeta.threadDisplayName ?? source.name;
const folderName = source.threadMeta.folderName ?? (source.isGroup ? "群聊" : source.name);
const groupMembers = memberProjects.map((memberProject) => ({
projectId: memberProject.id,
deviceId: memberProject.deviceIds[0] ?? memberProject.id,
threadId: memberProject.threadMeta.threadId,
threadDisplayName: memberProject.threadMeta.threadDisplayName,
folderName: memberProject.threadMeta.folderName,
}));
const nextProject = normalizeProject({
id: projectId,
name: threadDisplayName,
pinned: false,
systemPinned: false,
deviceIds: dedupeStrings(groupMembers.map((member) => member.deviceId)),
preview: `已创建群聊《${threadDisplayName}`,
updatedAt: now,
lastMessageAt: now,
isGroup: true,
unreadCount: 0,
riskLevel: source.riskLevel,
threadMeta: {
projectId,
threadId,
threadDisplayName,
folderName,
activityIconCount: Math.max(1, memberProjects.length),
updatedAt: now,
},
groupMembers,
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
messages: [
{
id: randomToken("msg"),
sender: "master",
senderLabel: input.createdBy || "群聊创建",
body: `已由 ${input.createdBy || "系统"} 创建群聊《${threadDisplayName}》。`,
sentAt: now,
kind: "text",
},
],
goals: [],
versions: [],
});
state.projects.unshift(nextProject);
return nextProject;
});
publishBossEvent("project.messages.updated", { projectId: project.id });
publishBossEvent("conversation.updated", { projectId: project.id });
return project;
}
export async function appendProjectMessage(payload: {
projectId: string;
sender?: MessageSender;
@@ -3871,49 +4287,198 @@ export async function appendProjectMessage(payload: {
return message;
}
export async function forwardProjectMessage(payload: {
sourceProjectId: string;
targetProjectId: string;
note: string;
function findProjectMessage(project: Project, messageId: string) {
return project.messages.find((message) => message.id === messageId) ?? null;
}
function requiresForwardApproval(source: Project, target: Project) {
return source.collaborationMode === "approval_required" && target.id !== "master-agent";
}
function buildForwardSingleMessage(input: {
source: Project;
target: Project;
message: Message;
requestedBy: string;
}) {
const sentAt = nowIso();
const body = `转发自《${input.source.name}》到《${input.target.name}》:${input.message.body}`;
return {
id: randomToken("forward"),
sender: "user" as const,
senderLabel: "你",
body,
sentAt,
kind: "forward_single" as const,
forwardSource: {
sourceProjectId: input.source.id,
sourceProjectName: input.source.name,
sourceThreadId: input.source.threadMeta?.threadId,
sourceThreadTitle: input.source.threadMeta?.threadDisplayName,
sourceMessageId: input.message.id,
forwardedBy: input.requestedBy,
forwardedAt: sentAt,
},
} satisfies Message;
}
function buildForwardBundleMessage(input: {
source: Project;
target: Project;
messages: Message[];
requestedBy: string;
}) {
const sentAt = nowIso();
const startedAt = input.messages[0]?.sentAt ?? sentAt;
const endedAt = input.messages[input.messages.length - 1]?.sentAt ?? sentAt;
const body = `转发自《${input.source.name}》到《${input.target.name}》:${input.messages.length} 条消息,最后一条:${
input.messages[input.messages.length - 1]?.body ?? ""
}`;
return {
id: randomToken("forward"),
sender: "user" as const,
senderLabel: "你",
body,
sentAt,
kind: "forward_bundle" as const,
forwardBundle: {
sourceProjectId: input.source.id,
sourceProjectName: input.source.name,
sourceThreadId: input.source.threadMeta?.threadId,
sourceThreadTitle: input.source.threadMeta?.threadDisplayName,
itemCount: input.messages.length,
startedAt,
endedAt,
items: input.messages.map((message) => ({
messageId: message.id,
senderLabel: message.senderLabel,
body: message.body,
kind: message.kind ?? "text",
sentAt: message.sentAt,
})),
},
} satisfies Message;
}
export async function forwardProjectMessage(
payload:
| {
sourceProjectId: string;
mode: "single";
targetProjectId: string;
sourceMessageId: string;
requestedBy: string;
}
| {
sourceProjectId: string;
mode: "bundle";
targetProjectId: string;
sourceMessageIds: string[];
requestedBy: string;
},
) {
const state = await readState();
const source = state.projects.find((item) => item.id === payload.sourceProjectId);
const target = state.projects.find((item) => item.id === payload.targetProjectId);
if (!source || !target) throw new Error("PROJECT_NOT_FOUND");
if (requiresForwardApproval(source, target)) {
return {
approvalRequired: true,
approvalReason: "NON_DEVELOPMENT_THREAD_FORWARD",
} as const;
}
if (payload.mode === "single") {
const sourceMessage = findProjectMessage(source, payload.sourceMessageId);
if (!sourceMessage) throw new Error("MESSAGE_NOT_FOUND");
const message = await mutateState((state) => {
const sourceProject = state.projects.find((item) => item.id === payload.sourceProjectId);
const targetProject = state.projects.find((item) => item.id === payload.targetProjectId);
if (!sourceProject || !targetProject) throw new Error("PROJECT_NOT_FOUND");
const sourceLedgerMessage = findProjectMessage(sourceProject, payload.sourceMessageId);
if (!sourceLedgerMessage) throw new Error("MESSAGE_NOT_FOUND");
const message = buildForwardSingleMessage({
source: sourceProject,
target: targetProject,
message: sourceLedgerMessage,
requestedBy: payload.requestedBy,
});
targetProject.messages.push(message);
targetProject.unreadCount += 1;
targetProject.lastMessageAt = message.sentAt;
targetProject.preview = message.body;
sourceProject.messages.push({
id: randomToken("forward-log"),
sender: "master",
senderLabel: "主 Agent",
body: `已转发到《${targetProject.name}》。`,
sentAt: message.sentAt,
kind: "forward_notice",
});
sourceProject.lastMessageAt = message.sentAt;
return message;
});
publishBossEvent("project.messages.updated", { projectId: payload.sourceProjectId });
publishBossEvent("project.messages.updated", { projectId: payload.targetProjectId });
publishBossEvent("conversation.updated", { projectId: payload.sourceProjectId });
publishBossEvent("conversation.updated", { projectId: payload.targetProjectId });
return { message };
}
const sourceMessageIds = payload.mode === "bundle" ? payload.sourceMessageIds : [];
const sourceMessages = sourceMessageIds
.map((messageId) => findProjectMessage(source, messageId))
.filter((message): message is Message => Boolean(message));
if (sourceMessages.length <= 1 || sourceMessages.length !== sourceMessageIds.length) {
throw new Error("MESSAGE_NOT_FOUND");
}
const message = await mutateState((state) => {
const source = state.projects.find((item) => item.id === payload.sourceProjectId);
const target = state.projects.find((item) => item.id === payload.targetProjectId);
if (!source || !target) throw new Error("PROJECT_NOT_FOUND");
if (!payload.note.trim()) throw new Error("FORWARD_NOTE_REQUIRED");
const sourceProject = state.projects.find((item) => item.id === payload.sourceProjectId);
const targetProject = state.projects.find((item) => item.id === payload.targetProjectId);
if (!sourceProject || !targetProject) throw new Error("PROJECT_NOT_FOUND");
const sentAt = nowIso();
const forwardBody = `转发自《${source.name}》:${payload.note.trim()}`;
const message: Message = {
id: randomToken("forward"),
sender: "user",
senderLabel: "你",
body: forwardBody,
sentAt,
kind: "forward_notice",
};
const bundleMessages = sourceMessageIds
.map((messageId) => findProjectMessage(sourceProject, messageId))
.filter((item): item is Message => Boolean(item));
if (bundleMessages.length <= 1 || bundleMessages.length !== sourceMessageIds.length) {
throw new Error("MESSAGE_NOT_FOUND");
}
target.messages.push(message);
target.unreadCount += 1;
target.lastMessageAt = sentAt;
target.preview = forwardBody;
const message = buildForwardBundleMessage({
source: sourceProject,
target: targetProject,
messages: bundleMessages,
requestedBy: payload.requestedBy,
});
source.messages.push({
targetProject.messages.push(message);
targetProject.unreadCount += 1;
targetProject.lastMessageAt = message.sentAt;
targetProject.preview = message.body;
sourceProject.messages.push({
id: randomToken("forward-log"),
sender: "master",
senderLabel: "主 Agent",
body: `把消息转发到《${target.name}》。`,
sentAt,
body: `已转发到《${targetProject.name}》。`,
sentAt: message.sentAt,
kind: "forward_notice",
});
source.lastMessageAt = sentAt;
sourceProject.lastMessageAt = message.sentAt;
return message;
});
publishBossEvent("project.messages.updated", { projectId: payload.sourceProjectId });
publishBossEvent("project.messages.updated", { projectId: payload.targetProjectId });
publishBossEvent("conversation.updated", { projectId: payload.sourceProjectId });
publishBossEvent("conversation.updated", { projectId: payload.targetProjectId });
return message;
return { message };
}
export async function updateUserSettings(settings: Partial<UserSettings>) {

View File

@@ -34,7 +34,12 @@ export interface ConversationItem {
conversationType: "master_agent" | "single_device" | "group";
projectId: string;
projectTitle: string;
threadTitle: string;
folderLabel: string;
preview: string;
lastMessagePreview: string;
activityIconCount: number;
topPinnedLabel?: "置顶";
manualPinned: boolean;
latestReplyAt: string;
latestReplyLabel: string;
@@ -47,6 +52,11 @@ export interface ConversationItem {
secondary?: string;
overflowCount?: number;
};
groupMembers?: Array<{
threadId: string;
avatar: string;
title: string;
}>;
contextBudgetIndicator: ContextIndicator;
contextBudgetSourceNodeId?: string;
contextBudgetUpdatedAt?: string;
@@ -170,6 +180,22 @@ function projectType(project: Project): ConversationItem["conversationType"] {
return project.isGroup ? "group" : "single_device";
}
function isTopPinnedConversation(project: Project) {
return Boolean(project.pinned || project.systemPinned || project.id === "audit-collab");
}
function getThreadAvatarFallback(title: string) {
const trimmed = title.trim();
if (!trimmed) return "A";
return trimmed.slice(0, 1).toUpperCase();
}
function getGroupMemberAvatar(member: Project["groupMembers"][number], device?: Device) {
const avatar = device?.avatar?.trim();
if (avatar) return avatar;
return getThreadAvatarFallback(member.threadDisplayName);
}
function aiRoleLabel(role: AiAccountRole) {
switch (role) {
case "primary":
@@ -281,13 +307,32 @@ export function getConversationItems(state: BossState): ConversationItem[] {
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
const threadViews = threadViewsForProject(state, project.id);
const topThread = threadViews[0]?.snapshot;
const threadTitle = project.threadMeta?.threadDisplayName ?? project.name;
const folderLabel = project.threadMeta?.folderName ?? "";
const activityIconCount = project.threadMeta?.activityIconCount ?? 1;
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
const groupMembers = project.isGroup
? project.groupMembers.map((member) => ({
threadId: member.threadId,
avatar: getGroupMemberAvatar(
member,
state.devices.find((device) => device.id === member.deviceId),
),
title: member.threadDisplayName,
}))
: undefined;
return {
conversationId: `conv-${project.id}`,
conversationType: projectType(project),
projectId: project.id,
projectTitle: project.name,
threadTitle,
folderLabel,
preview: project.preview,
lastMessagePreview: project.preview,
activityIconCount,
topPinnedLabel,
manualPinned: Boolean(project.pinned && !project.systemPinned),
latestReplyAt: project.lastMessageAt,
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
@@ -300,6 +345,7 @@ export function getConversationItems(state: BossState): ConversationItem[] {
secondary: project.isGroup ? devices[1]?.avatar : undefined,
overflowCount: Math.max(0, devices.length - 2) || undefined,
},
groupMembers,
contextBudgetIndicator: {
visible: !project.isGroup && Boolean(topThread),
style: "ring_percent",
@@ -315,7 +361,9 @@ export function getConversationItems(state: BossState): ConversationItem[] {
return conversations.sort((a, b) => {
if (a.projectId === "master-agent") return -1;
if (b.projectId === "master-agent") return 1;
if (a.manualPinned !== b.manualPinned) return a.manualPinned ? -1 : 1;
const aPinned = Boolean(a.topPinnedLabel);
const bPinned = Boolean(b.topPinnedLabel);
if (aPinned !== bPinned) return aPinned ? -1 : 1;
return b.latestReplyAt.localeCompare(a.latestReplyAt);
});
}