diff --git a/README.md b/README.md
index 2c98edb..b7bd1e5 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@
- `src/boss_control`:空占位目录,不参与当前运行
- `src/boss_device_agent`:空占位目录,不参与当前运行
-## 当前运行状态(2026-03-27)
+## 当前运行状态(2026-03-28)
本地:
@@ -90,17 +90,20 @@ 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.2.1`(`versionCode=10`)
+- 当前最新 release 构建版本:`2.3.0`(`versionCode=11`)
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
-- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
-- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
-- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维 / 审计 / 修复` 不再出现在一级 `我的`,而是下沉到 `关于 > 高级与调试`
+- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、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 / 审计对话` 普通置顶会话化
## 本地启动
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 20763b7..07f9d4b 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -33,8 +33,8 @@ android {
applicationId "com.hyzq.boss"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 10
- versionName "2.2.1"
+ versionCode 11
+ versionName "2.3.0"
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 31d2b9b..960a370 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -32,6 +32,9 @@
+
+
+
diff --git a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java
index 3727bf8..887a4f0 100644
--- a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java
@@ -25,6 +25,7 @@ public class AboutActivity extends BossScreenActivity {
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;
@@ -33,6 +34,7 @@ public class AboutActivity extends BossScreenActivity {
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;
@@ -64,7 +66,7 @@ public class AboutActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- configureScreen("关于", "版本与更新");
+ configureScreen("关于", "版本与 OTA 更新");
restoreDownloadUiState();
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -93,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(() -> {
@@ -111,80 +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.buildListRow(
- this,
- "当前版本",
- user.optString("version", "-")
- + " · 账号 " + user.optString("account", "-"),
- "绑定 Codex " + user.optString("boundCodexNodeLabel", "未绑定")
- + (session == null ? "" : " · 会话到期 " + session.optString("expiresAt", "-")),
- null,
- null
- ));
- }
-
- JSONObject availableRelease = ota.optJSONObject("availableRelease");
- String otaSubtitle = availableRelease == null
- ? "当前已经是最新版本。"
- : "可用版本 " + availableRelease.optString("version", "未知版本");
- String otaMeta = availableRelease == null
- ? "当前版本 " + ota.optString("currentVersion", "-")
- : availableRelease.optString("summary", "暂无摘要")
- + " · 文件 " + availableRelease.optString("packageFileName", "-");
- appendContent(BossUi.buildListRow(
+ invalidateStaleDownloadedApk(ota.optJSONObject("availableRelease"));
+ appendContent(BossUi.buildWechatMenuRow(
this,
- "OTA 状态",
- otaSubtitle,
- otaMeta,
- availableRelease == null ? null : "NEW",
+ "当前版本",
+ user == null ? ota.optString("currentVersion", "-") : user.optString("version", ota.optString("currentVersion", "-")),
+ "已安装版本",
+ null,
null
));
- appendContent(BossUi.buildMenuRow(this, "检查更新", "拉取最新 OTA 状态", null, v -> performOtaAction("check")));
- appendContent(BossUi.buildMenuRow(this, "登记应用 OTA", "把当前已应用版本写回服务端", null, v -> performOtaAction("apply")));
- appendContent(BossUi.buildMenuRow(this, "应用内下载 APK", "下载最新安装包并拉起系统安装器", null, v -> downloadLatestApk()));
+ JSONObject availableRelease = ota.optJSONObject("availableRelease");
+ appendContent(BossUi.buildWechatMenuRow(
+ this,
+ "OTA 状态",
+ buildOtaStatusSubtitle(ota),
+ buildOtaStatusMeta(ota),
+ availableRelease == null ? null : "OTA",
+ null
+ ));
+
+ 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();
- appendContent(BossUi.buildMenuRow(
- this,
- WechatSurfaceMapper.advancedEntryTitle(),
- "进入运维对话、审计与修复入口",
- null,
- v -> startActivity(new Intent(this, OpsCenterActivity.class))
- ));
+ setRefreshing(false);
+ }
- 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.buildListRow(
- this,
- log.optString("version", "OTA"),
- log.optString("summary", ""),
- log.optString("status", "-") + " · " + log.optString("createdAt", "-"),
- null,
- null
- ));
+ 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) {
@@ -196,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 {
@@ -224,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);
@@ -237,6 +337,7 @@ public class AboutActivity extends BossScreenActivity {
downloadedApkUri = null;
lastDownloadFileName = fileName;
+ lastDownloadVersion = releaseVersion;
lastDownloadStatus = DownloadManager.STATUS_PENDING;
lastDownloadedBytes = 0L;
lastTotalBytes = -1L;
@@ -345,6 +446,8 @@ public class AboutActivity extends BossScreenActivity {
@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);
@@ -435,6 +538,7 @@ public class AboutActivity extends BossScreenActivity {
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);
@@ -454,10 +558,91 @@ public class AboutActivity extends BossScreenActivity {
.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);
diff --git a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java
index 2d13063..6401f5e 100644
--- a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java
@@ -49,13 +49,11 @@ public class AiAccountsActivity extends BossScreenActivity {
JSONArray accounts = payload.optJSONArray("accounts");
JSONObject activeIdentity = payload.optJSONObject("activeIdentity");
replaceContent();
- appendContent(BossUi.buildListRow(
+ appendContent(BossUi.buildSoftPanel(
this,
- "账号管理",
- "管理主 GPT、备用 GPT 与 API 容灾。",
- "支持新增、编辑、激活、校验和删除。",
- null,
- null
+ "AI 账号",
+ "这里统一管理主 GPT、备用 GPT 与 API 容灾账号。",
+ "轻点条目可编辑,按钮可切换、校验或删除。"
));
appendContent(buildActiveIdentityCard(activeIdentity));
appendContent(buildAccountsSection(accounts));
@@ -64,25 +62,29 @@ public class AiAccountsActivity extends BossScreenActivity {
private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) {
if (activeIdentity == null) {
- return BossUi.buildListRow(this, "当前主控身份", "当前没有可用账号。", "请先新增或启用一个账号。", null, null);
+ return BossUi.buildSoftPanel(this, "当前主控身份", "当前没有可用账号。", "请先新增或启用一个账号。");
}
- String subtitle = activeIdentity.optString("label", "AI 账号")
- + " · " + activeIdentity.optString("displayName", "-");
- String meta = activeIdentity.optString("roleLabel", "-")
- + " · " + activeIdentity.optString("providerLabel", "-")
- + " · " + activeIdentity.optString("statusLabel", "-");
- return BossUi.buildListRow(this, "当前主控身份", subtitle, meta, "当前", null);
+ 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.buildListRow(
+ section.addView(BossUi.buildWechatMenuRow(
this,
"账号列表",
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点开可编辑,按钮可激活、校验或删除。",
- "当前 API:/api/v1/accounts",
+ null,
null,
null
));
@@ -116,7 +118,7 @@ public class AiAccountsActivity extends BossScreenActivity {
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
- card.addView(BossUi.buildListRow(
+ card.addView(BossUi.buildWechatMenuRow(
this,
account.optString("label", "未命名账号"),
subtitle.toString(),
@@ -125,22 +127,16 @@ public class AiAccountsActivity extends BossScreenActivity {
v -> openAccountEditor(account, null)
));
- Button activate = BossUi.buildPrimaryButton(this, account.optBoolean("isActive") ? "已激活" : "设为当前主控");
+ Button activate = BossUi.buildMiniActionButton(this, account.optBoolean("isActive") ? "当前主控" : "设为当前", !account.optBoolean("isActive"));
activate.setEnabled(!account.optBoolean("isActive"));
activate.setOnClickListener(v -> activateAccount(account));
- card.addView(activate);
- Button validate = BossUi.buildSecondaryButton(this, "校验连接");
+ Button validate = BossUi.buildMiniActionButton(this, "校验连接", false);
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, "删除账号");
+ Button delete = BossUi.buildMiniActionButton(this, "删除账号", false);
delete.setOnClickListener(v -> confirmDeleteAccount(account));
- card.addView(delete);
+ card.addView(BossUi.buildInlineActionRow(this, activate, validate, delete));
return card;
}
@@ -182,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 账号")
diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java
index fec7ca9..087ebf0 100644
--- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java
+++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java
@@ -72,6 +72,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);
diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java
index f115cc7..4f2e1f4 100644
--- a/android/app/src/main/java/com/hyzq/boss/BossUi.java
+++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java
@@ -1,14 +1,20 @@
package com.hyzq.boss;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
+import android.graphics.Color;
import android.graphics.Typeface;
+import android.graphics.drawable.GradientDrawable;
import android.text.TextUtils;
+import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
+import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -16,6 +22,22 @@ import android.widget.Toast;
import androidx.annotation.Nullable;
public final class BossUi {
+ private static final int[] AVATAR_BG_COLORS = {
+ Color.parseColor("#1EC76F"),
+ Color.parseColor("#DFF5EC"),
+ Color.parseColor("#DCEEF6"),
+ Color.parseColor("#FCE5BF")
+ };
+ private static final int[] ACTIVITY_ICON_COLORS = {
+ Color.parseColor("#C4CCC8"),
+ Color.parseColor("#B5C0BA"),
+ Color.parseColor("#A7B3AD"),
+ Color.parseColor("#98A6A0")
+ };
+ private static final int DEVICE_STATUS_ONLINE = Color.parseColor("#18B85A");
+ private static final int DEVICE_STATUS_ABNORMAL = Color.parseColor("#FF5A5A");
+ private static final int DEVICE_STATUS_OFFLINE = Color.parseColor("#A7AFB7");
+
private BossUi() {}
public static LinearLayout buildListRow(
@@ -167,10 +189,482 @@ public final class BossUi {
return buildListRow(context, title, description, null, badge, listener);
}
+ public static LinearLayout buildWechatMenuRow(
+ Context context,
+ String title,
+ @Nullable String subtitle,
+ @Nullable String meta,
+ @Nullable String badge,
+ @Nullable View.OnClickListener listener
+ ) {
+ return buildListRow(context, title, subtitle, meta, badge, listener);
+ }
+
+ public static LinearLayout buildSimpleProfileHeader(
+ Context context,
+ String name,
+ String subtitle,
+ @Nullable String detail
+ ) {
+ LinearLayout card = new LinearLayout(context);
+ card.setOrientation(LinearLayout.HORIZONTAL);
+ card.setGravity(Gravity.CENTER_VERTICAL);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ params.leftMargin = dp(context, 12);
+ params.rightMargin = dp(context, 12);
+ params.bottomMargin = dp(context, 12);
+ card.setLayoutParams(params);
+ card.setPadding(dp(context, 16), dp(context, 16), dp(context, 16), dp(context, 16));
+ card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18)));
+ card.setElevation(dp(context, 1));
+
+ TextView avatar = new TextView(context);
+ LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(dp(context, 64), dp(context, 64));
+ avatar.setLayoutParams(avatarParams);
+ avatar.setGravity(Gravity.CENTER);
+ avatar.setText(firstLetter(name));
+ avatar.setTextSize(28);
+ avatar.setTypeface(Typeface.DEFAULT_BOLD);
+ avatar.setTextColor(context.getColor(R.color.boss_green));
+ avatar.setBackground(createRoundedBackground(Color.parseColor("#DFF3E8"), dp(context, 18)));
+ card.addView(avatar);
+
+ LinearLayout textWrap = new LinearLayout(context);
+ textWrap.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
+ 0,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ 1f
+ );
+ textParams.leftMargin = dp(context, 14);
+ textWrap.setLayoutParams(textParams);
+
+ TextView titleView = new TextView(context);
+ titleView.setText(TextUtils.isEmpty(name) ? "我的" : name);
+ titleView.setTextSize(17);
+ titleView.setTypeface(Typeface.DEFAULT_BOLD);
+ titleView.setTextColor(context.getColor(R.color.boss_text_primary));
+ textWrap.addView(titleView);
+
+ TextView subtitleView = new TextView(context);
+ subtitleView.setText(subtitle);
+ subtitleView.setTextSize(13);
+ subtitleView.setTextColor(context.getColor(R.color.boss_text_muted));
+ subtitleView.setPadding(0, dp(context, 4), 0, 0);
+ textWrap.addView(subtitleView);
+
+ if (!TextUtils.isEmpty(detail)) {
+ TextView detailView = new TextView(context);
+ detailView.setText(detail);
+ detailView.setTextSize(12);
+ detailView.setTextColor(context.getColor(R.color.boss_text_soft));
+ detailView.setPadding(0, dp(context, 6), 0, 0);
+ textWrap.addView(detailView);
+ }
+
+ card.addView(textWrap);
+ return card;
+ }
+
+ public static LinearLayout buildFormCell(
+ Context context,
+ String label,
+ @Nullable String helper,
+ View field
+ ) {
+ LinearLayout cell = new LinearLayout(context);
+ cell.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ params.leftMargin = dp(context, 12);
+ params.rightMargin = dp(context, 12);
+ params.bottomMargin = dp(context, 10);
+ cell.setLayoutParams(params);
+ cell.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14));
+ cell.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18)));
+
+ TextView labelView = new TextView(context);
+ labelView.setText(label);
+ labelView.setTextSize(14);
+ labelView.setTypeface(Typeface.DEFAULT_BOLD);
+ labelView.setTextColor(context.getColor(R.color.boss_text_primary));
+ cell.addView(labelView);
+
+ if (!TextUtils.isEmpty(helper)) {
+ TextView helperView = new TextView(context);
+ helperView.setText(helper);
+ helperView.setTextSize(12);
+ helperView.setTextColor(context.getColor(R.color.boss_text_soft));
+ helperView.setPadding(0, dp(context, 4), 0, dp(context, 10));
+ cell.addView(helperView);
+ }
+
+ field.setPadding(field.getPaddingLeft(), field.getPaddingTop(), field.getPaddingRight(), field.getPaddingBottom());
+ cell.addView(field);
+ return cell;
+ }
+
+ public static LinearLayout buildDeviceCard(
+ Context context,
+ WechatSurfaceMapper.DeviceRow row,
+ @Nullable View.OnClickListener listener,
+ @Nullable View.OnLongClickListener longClickListener
+ ) {
+ LinearLayout card = new LinearLayout(context);
+ card.setOrientation(LinearLayout.HORIZONTAL);
+ card.setGravity(Gravity.CENTER_VERTICAL);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ params.leftMargin = dp(context, 12);
+ params.rightMargin = dp(context, 12);
+ params.bottomMargin = dp(context, 12);
+ card.setLayoutParams(params);
+ card.setPadding(dp(context, 14), dp(context, 14), dp(context, 14), dp(context, 14));
+ card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18)));
+ card.setElevation(dp(context, 1));
+ if (listener != null) {
+ card.setClickable(true);
+ card.setFocusable(true);
+ card.setOnClickListener(listener);
+ }
+ if (longClickListener != null) {
+ card.setOnLongClickListener(longClickListener);
+ }
+
+ FrameLayout avatarWrap = new FrameLayout(context);
+ LinearLayout.LayoutParams avatarWrapParams = new LinearLayout.LayoutParams(dp(context, 56), dp(context, 56));
+ avatarWrap.setLayoutParams(avatarWrapParams);
+
+ TextView avatar = new TextView(context);
+ FrameLayout.LayoutParams avatarParams = new FrameLayout.LayoutParams(dp(context, 52), dp(context, 52));
+ avatar.setLayoutParams(avatarParams);
+ avatar.setGravity(Gravity.CENTER);
+ avatar.setText(firstLetter(firstNonEmpty(row.avatarLabel, row.title, "设")));
+ avatar.setTextSize(22);
+ avatar.setTypeface(Typeface.DEFAULT_BOLD);
+ avatar.setTextColor(resolveDeviceAccentColor(row.statusKey));
+ avatar.setBackground(createRoundedBackground(resolveDeviceAvatarBackground(row.statusKey), dp(context, 18)));
+ avatarWrap.addView(avatar);
+
+ View statusDot = new View(context);
+ FrameLayout.LayoutParams dotParams = new FrameLayout.LayoutParams(dp(context, 10), dp(context, 10), Gravity.END | Gravity.CENTER_VERTICAL);
+ dotParams.rightMargin = dp(context, 1);
+ statusDot.setLayoutParams(dotParams);
+ statusDot.setBackground(createRoundedBackground(resolveDeviceAccentColor(row.statusKey), dp(context, 5)));
+ avatarWrap.addView(statusDot);
+
+ card.addView(avatarWrap);
+
+ LinearLayout textWrap = new LinearLayout(context);
+ textWrap.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
+ 0,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ 1f
+ );
+ textParams.leftMargin = dp(context, 12);
+ textWrap.setLayoutParams(textParams);
+
+ TextView titleView = new TextView(context);
+ titleView.setText(TextUtils.isEmpty(row.title) ? "设备" : row.title);
+ titleView.setTextSize(17);
+ titleView.setTypeface(Typeface.DEFAULT_BOLD);
+ titleView.setTextColor(context.getColor(R.color.boss_text_primary));
+ titleView.setMaxLines(1);
+ titleView.setEllipsize(TextUtils.TruncateAt.END);
+ textWrap.addView(titleView);
+
+ TextView subtitleView = new TextView(context);
+ subtitleView.setText(row.subtitle);
+ subtitleView.setTextSize(13);
+ subtitleView.setTextColor(context.getColor(R.color.boss_text_muted));
+ subtitleView.setPadding(0, dp(context, 5), 0, 0);
+ subtitleView.setMaxLines(2);
+ subtitleView.setEllipsize(TextUtils.TruncateAt.END);
+ textWrap.addView(subtitleView);
+
+ TextView metaView = new TextView(context);
+ metaView.setText(row.meta);
+ metaView.setTextSize(12);
+ metaView.setTextColor(resolveDeviceMetaColor(context, row.statusKey));
+ metaView.setPadding(0, dp(context, 6), 0, 0);
+ metaView.setMaxLines(2);
+ metaView.setEllipsize(TextUtils.TruncateAt.END);
+ textWrap.addView(metaView);
+
+ card.addView(textWrap);
+
+ if (listener != null) {
+ TextView arrowView = new TextView(context);
+ arrowView.setText("›");
+ arrowView.setTextSize(18);
+ arrowView.setTextColor(context.getColor(R.color.boss_text_soft));
+ arrowView.setPadding(dp(context, 8), 0, 0, 0);
+ card.addView(arrowView);
+ }
+ return card;
+ }
+
+ public static LinearLayout buildSoftPanel(
+ Context context,
+ String title,
+ String body,
+ @Nullable String meta
+ ) {
+ LinearLayout card = new LinearLayout(context);
+ card.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ params.leftMargin = dp(context, 12);
+ params.rightMargin = dp(context, 12);
+ params.bottomMargin = dp(context, 12);
+ card.setLayoutParams(params);
+ card.setPadding(dp(context, 16), dp(context, 16), dp(context, 16), dp(context, 16));
+ card.setBackground(createRoundedBackground(Color.parseColor("#E7F5ED"), dp(context, 18)));
+
+ TextView titleView = new TextView(context);
+ titleView.setText(title);
+ titleView.setTextSize(16);
+ titleView.setTypeface(Typeface.DEFAULT_BOLD);
+ titleView.setTextColor(context.getColor(R.color.boss_text_primary));
+ card.addView(titleView);
+
+ TextView bodyView = new TextView(context);
+ bodyView.setText(body);
+ bodyView.setTextSize(14);
+ bodyView.setLineSpacing(0f, 1.2f);
+ bodyView.setTextColor(context.getColor(R.color.boss_text_primary));
+ bodyView.setPadding(0, dp(context, 8), 0, 0);
+ card.addView(bodyView);
+
+ if (!TextUtils.isEmpty(meta)) {
+ TextView metaView = new TextView(context);
+ metaView.setText(meta);
+ metaView.setTextSize(12);
+ metaView.setTextColor(context.getColor(R.color.boss_text_muted));
+ metaView.setPadding(0, dp(context, 10), 0, 0);
+ card.addView(metaView);
+ }
+ return card;
+ }
+
+ public static LinearLayout buildInlineActionRow(Context context, Button... buttons) {
+ LinearLayout row = new LinearLayout(context);
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ rowParams.leftMargin = dp(context, 12);
+ rowParams.rightMargin = dp(context, 12);
+ rowParams.bottomMargin = dp(context, 12);
+ row.setLayoutParams(rowParams);
+
+ for (int i = 0; i < buttons.length; i++) {
+ Button button = buttons[i];
+ LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams(
+ 0,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ 1f
+ );
+ if (i > 0) {
+ buttonParams.leftMargin = dp(context, 8);
+ }
+ button.setLayoutParams(buttonParams);
+ row.addView(button);
+ }
+ return row;
+ }
+
+ public static Button buildMiniActionButton(Context context, String label, boolean primary) {
+ Button button = new Button(context);
+ button.setText(label);
+ button.setAllCaps(false);
+ button.setTextSize(13);
+ button.setPadding(dp(context, 10), dp(context, 10), dp(context, 10), dp(context, 10));
+ button.setTextColor(context.getColor(primary ? R.color.boss_surface : R.color.boss_green));
+ button.setBackgroundResource(primary ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
+ return button;
+ }
+
+ public static LinearLayout buildConversationRow(
+ Context context,
+ WechatSurfaceMapper.ConversationRow row,
+ @Nullable View.OnClickListener listener
+ ) {
+ LinearLayout card = new LinearLayout(context);
+ card.setOrientation(LinearLayout.HORIZONTAL);
+ card.setGravity(Gravity.TOP);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ params.leftMargin = dp(context, 12);
+ params.rightMargin = dp(context, 12);
+ params.bottomMargin = dp(context, 12);
+ card.setLayoutParams(params);
+ card.setPadding(dp(context, 14), dp(context, 14), dp(context, 14), dp(context, 14));
+ card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18)));
+ card.setElevation(dp(context, 1));
+ if (listener != null) {
+ card.setClickable(true);
+ card.setFocusable(true);
+ card.setOnClickListener(listener);
+ }
+
+ card.addView(buildConversationAvatar(context, row));
+
+ LinearLayout centerColumn = new LinearLayout(context);
+ centerColumn.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout.LayoutParams centerParams = new LinearLayout.LayoutParams(
+ 0,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ 1f
+ );
+ centerParams.leftMargin = dp(context, 12);
+ centerParams.rightMargin = dp(context, 8);
+ centerColumn.setLayoutParams(centerParams);
+
+ TextView titleView = new TextView(context);
+ titleView.setText(TextUtils.isEmpty(row.threadTitle) ? "未命名会话" : row.threadTitle);
+ titleView.setTextSize(18);
+ titleView.setTypeface(Typeface.DEFAULT_BOLD);
+ titleView.setTextColor(context.getColor(R.color.boss_text_primary));
+ titleView.setMaxLines(1);
+ titleView.setEllipsize(TextUtils.TruncateAt.END);
+ centerColumn.addView(titleView);
+
+ if (!TextUtils.isEmpty(row.folderLabel)) {
+ TextView folderView = new TextView(context);
+ folderView.setText(row.folderLabel);
+ folderView.setTextSize(13);
+ folderView.setTextColor(context.getColor(R.color.boss_text_muted));
+ folderView.setPadding(0, dp(context, 4), 0, 0);
+ folderView.setMaxLines(1);
+ folderView.setEllipsize(TextUtils.TruncateAt.END);
+ centerColumn.addView(folderView);
+ }
+
+ TextView previewView = new TextView(context);
+ previewView.setText(TextUtils.isEmpty(row.lastMessagePreview) ? "暂无消息" : row.lastMessagePreview);
+ previewView.setTextSize(14);
+ previewView.setTextColor(context.getColor(R.color.boss_text_soft));
+ previewView.setPadding(0, dp(context, 5), 0, 0);
+ previewView.setMaxLines(2);
+ previewView.setEllipsize(TextUtils.TruncateAt.END);
+ centerColumn.addView(previewView);
+
+ card.addView(centerColumn);
+
+ LinearLayout trailingColumn = new LinearLayout(context);
+ trailingColumn.setOrientation(LinearLayout.VERTICAL);
+ trailingColumn.setGravity(Gravity.END);
+ trailingColumn.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.MATCH_PARENT
+ ));
+
+ if (!TextUtils.isEmpty(row.topPinnedLabel)) {
+ TextView pinnedView = new TextView(context);
+ pinnedView.setText(row.topPinnedLabel);
+ pinnedView.setTextSize(11);
+ pinnedView.setTypeface(Typeface.DEFAULT_BOLD);
+ pinnedView.setTextColor(context.getColor(R.color.boss_green));
+ pinnedView.setBackground(createRoundedBackground(Color.parseColor("#E7F7EE"), dp(context, 9)));
+ pinnedView.setPadding(dp(context, 7), dp(context, 3), dp(context, 7), dp(context, 3));
+ trailingColumn.addView(pinnedView);
+ }
+
+ TextView timeView = new TextView(context);
+ timeView.setText(TextUtils.isEmpty(row.timeLabel) ? "--:--" : row.timeLabel);
+ timeView.setTextSize(12);
+ timeView.setTextColor(context.getColor(R.color.boss_text_muted));
+ timeView.setPadding(0, dp(context, TextUtils.isEmpty(row.topPinnedLabel) ? 2 : 8), 0, 0);
+ trailingColumn.addView(timeView);
+
+ if (row.unreadCount > 0) {
+ TextView unreadView = new TextView(context);
+ unreadView.setText(row.unreadCount > 99 ? "99+" : String.valueOf(row.unreadCount));
+ unreadView.setTextSize(11);
+ unreadView.setTypeface(Typeface.DEFAULT_BOLD);
+ unreadView.setTextColor(Color.WHITE);
+ unreadView.setGravity(Gravity.CENTER);
+ unreadView.setMinWidth(dp(context, 20));
+ unreadView.setBackground(createRoundedBackground(Color.parseColor("#FF5A5A"), dp(context, 10)));
+ unreadView.setPadding(dp(context, 6), dp(context, 2), dp(context, 6), dp(context, 2));
+ LinearLayout.LayoutParams unreadParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ unreadParams.topMargin = dp(context, 8);
+ unreadView.setLayoutParams(unreadParams);
+ trailingColumn.addView(unreadView);
+ }
+
+ View spacer = new View(context);
+ spacer.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ 0,
+ 1f
+ ));
+ trailingColumn.addView(spacer);
+
+ LinearLayout activityWrap = new LinearLayout(context);
+ activityWrap.setOrientation(LinearLayout.HORIZONTAL);
+ activityWrap.setGravity(Gravity.END | Gravity.CENTER_VERTICAL);
+ LinearLayout.LayoutParams activityParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ activityParams.topMargin = dp(context, 10);
+ activityWrap.setLayoutParams(activityParams);
+ int activityCount = Math.max(0, Math.min(row.activityIconCount, WechatSurfaceMapper.maxConversationActivityIcons()));
+ for (int i = 0; i < activityCount; i++) {
+ View dot = buildAnimatedActivityDot(context, i);
+ if (i > 0) {
+ LinearLayout.LayoutParams dotParams = (LinearLayout.LayoutParams) dot.getLayoutParams();
+ dotParams.leftMargin = dp(context, 4);
+ dot.setLayoutParams(dotParams);
+ }
+ activityWrap.addView(dot);
+ }
+ trailingColumn.addView(activityWrap);
+
+ card.addView(trailingColumn);
+ return card;
+ }
+
public static LinearLayout buildEmptyCard(Context context, String text) {
return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。");
}
+ public static TextView buildHintPill(Context context, String text) {
+ TextView pill = new TextView(context);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ params.leftMargin = dp(context, 12);
+ params.rightMargin = dp(context, 12);
+ params.bottomMargin = dp(context, 12);
+ pill.setLayoutParams(params);
+ pill.setText(text);
+ pill.setTextSize(12);
+ pill.setTextColor(context.getColor(R.color.boss_text_muted));
+ pill.setPadding(dp(context, 12), dp(context, 7), dp(context, 12), dp(context, 7));
+ pill.setBackground(createRoundedBackground(Color.parseColor("#F4F5F2"), dp(context, 14)));
+ return pill;
+ }
+
public static LinearLayout buildMessageBubble(
Context context,
String senderLabel,
@@ -312,4 +806,202 @@ public final class BossUi {
public static int dp(Context context, int value) {
return Math.round(value * context.getResources().getDisplayMetrics().density);
}
+
+ private static View buildConversationAvatar(Context context, WechatSurfaceMapper.ConversationRow row) {
+ if (!row.isGroup) {
+ return buildAvatarCircle(
+ context,
+ firstNonEmpty(row.avatarPrimary, row.threadTitle, "会"),
+ AVATAR_BG_COLORS[0],
+ Color.WHITE,
+ 52
+ );
+ }
+
+ FrameLayout groupWrap = new FrameLayout(context);
+ LinearLayout.LayoutParams wrapParams = new LinearLayout.LayoutParams(dp(context, 52), dp(context, 52));
+ groupWrap.setLayoutParams(wrapParams);
+
+ GradientDrawable bg = createRoundedBackground(Color.parseColor("#F2F6F3"), dp(context, 18));
+ bg.setStroke(dp(context, 1), Color.parseColor("#E5ECE7"));
+ groupWrap.setBackground(bg);
+
+ int visibleCount = Math.min(row.groupAvatarMembers.length, 4);
+ if (visibleCount == 0) {
+ groupWrap.addView(buildCenteredAvatarTile(context, "群", AVATAR_BG_COLORS[1], 32, 10, 10));
+ return groupWrap;
+ }
+
+ int[][] offsets = {
+ {4, 4},
+ {24, 4},
+ {4, 24},
+ {24, 24}
+ };
+ for (int i = 0; i < visibleCount; i++) {
+ WechatSurfaceMapper.GroupAvatarMember member = row.groupAvatarMembers[i];
+ groupWrap.addView(buildCenteredAvatarTile(
+ context,
+ firstNonEmpty(member.avatarLabel, member.title, "群"),
+ AVATAR_BG_COLORS[(i + 1) % AVATAR_BG_COLORS.length],
+ 22,
+ offsets[i][0],
+ offsets[i][1]
+ ));
+ }
+ return groupWrap;
+ }
+
+ private static TextView buildAvatarCircle(
+ Context context,
+ String labelSource,
+ int backgroundColor,
+ int textColor,
+ int sizeDp
+ ) {
+ TextView avatarView = new TextView(context);
+ LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(dp(context, sizeDp), dp(context, sizeDp));
+ avatarView.setLayoutParams(avatarParams);
+ avatarView.setGravity(Gravity.CENTER);
+ avatarView.setText(firstLetter(labelSource));
+ avatarView.setTextSize(sizeDp >= 48 ? 24 : 12);
+ avatarView.setTypeface(Typeface.DEFAULT_BOLD);
+ avatarView.setTextColor(textColor);
+ avatarView.setBackground(createRoundedBackground(backgroundColor, dp(context, sizeDp / 2)));
+ return avatarView;
+ }
+
+ private static View buildCenteredAvatarTile(
+ Context context,
+ String labelSource,
+ int backgroundColor,
+ int sizeDp,
+ int leftDp,
+ int topDp
+ ) {
+ TextView tile = buildAvatarCircle(context, labelSource, backgroundColor, context.getColor(R.color.boss_text_primary), sizeDp);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(dp(context, sizeDp), dp(context, sizeDp));
+ params.leftMargin = dp(context, leftDp);
+ params.topMargin = dp(context, topDp);
+ tile.setLayoutParams(params);
+ return tile;
+ }
+
+ private static GradientDrawable createRoundedBackground(int color, int radiusPx) {
+ GradientDrawable drawable = new GradientDrawable();
+ drawable.setColor(color);
+ drawable.setCornerRadius(radiusPx);
+ return drawable;
+ }
+
+ private static int resolveDeviceAccentColor(String statusKey) {
+ if ("online".equals(statusKey)) {
+ return DEVICE_STATUS_ONLINE;
+ }
+ if ("abnormal".equals(statusKey)) {
+ return DEVICE_STATUS_ABNORMAL;
+ }
+ return DEVICE_STATUS_OFFLINE;
+ }
+
+ private static int resolveDeviceAvatarBackground(String statusKey) {
+ if ("online".equals(statusKey)) {
+ return Color.parseColor("#E5F6EC");
+ }
+ if ("abnormal".equals(statusKey)) {
+ return Color.parseColor("#FCE9E7");
+ }
+ return Color.parseColor("#F2F4F7");
+ }
+
+ private static int resolveDeviceMetaColor(Context context, String statusKey) {
+ if ("online".equals(statusKey)) {
+ return context.getColor(R.color.boss_text_muted);
+ }
+ return resolveDeviceAccentColor(statusKey);
+ }
+
+ private static View buildAnimatedActivityDot(Context context, int index) {
+ View dot = new View(context);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(context, 7), dp(context, 7));
+ dot.setLayoutParams(params);
+
+ GradientDrawable drawable = createRoundedBackground(
+ ACTIVITY_ICON_COLORS[index % ACTIVITY_ICON_COLORS.length],
+ dp(context, 4)
+ );
+ dot.setBackground(drawable);
+ dot.setAlpha(0.35f);
+ dot.setScaleX(0.82f);
+ dot.setScaleY(0.82f);
+
+ final AnimatorSet[] animatorRef = new AnimatorSet[1];
+ dot.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(View v) {
+ cancelConversationActivityAnimator(animatorRef);
+ animatorRef[0] = createConversationActivityAnimator(v, index);
+ animatorRef[0].start();
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View v) {
+ cancelConversationActivityAnimator(animatorRef);
+ v.animate().cancel();
+ v.setAlpha(0.35f);
+ v.setScaleX(0.82f);
+ v.setScaleY(0.82f);
+ }
+ });
+ return dot;
+ }
+
+ private static AnimatorSet createConversationActivityAnimator(View dot, int index) {
+ long durationMs = 850L;
+ long startDelayMs = index * 120L;
+ ObjectAnimator alpha = ObjectAnimator.ofFloat(dot, View.ALPHA, 0.35f, 1f, 0.35f);
+ alpha.setDuration(durationMs);
+ alpha.setStartDelay(startDelayMs);
+ alpha.setRepeatCount(ObjectAnimator.INFINITE);
+
+ ObjectAnimator scaleX = ObjectAnimator.ofFloat(dot, View.SCALE_X, 0.82f, 1.1f, 0.82f);
+ scaleX.setDuration(durationMs);
+ scaleX.setStartDelay(startDelayMs);
+ scaleX.setRepeatCount(ObjectAnimator.INFINITE);
+
+ ObjectAnimator scaleY = ObjectAnimator.ofFloat(dot, View.SCALE_Y, 0.82f, 1.1f, 0.82f);
+ scaleY.setDuration(durationMs);
+ scaleY.setStartDelay(startDelayMs);
+ scaleY.setRepeatCount(ObjectAnimator.INFINITE);
+
+ AnimatorSet pulse = new AnimatorSet();
+ pulse.setInterpolator(new AccelerateDecelerateInterpolator());
+ pulse.playTogether(alpha, scaleX, scaleY);
+ return pulse;
+ }
+
+ private static void cancelConversationActivityAnimator(AnimatorSet[] animatorRef) {
+ if (animatorRef[0] != null) {
+ animatorRef[0].cancel();
+ animatorRef[0] = null;
+ }
+ }
+
+ private static String firstLetter(String value) {
+ String text = value == null ? "" : value.trim();
+ if (text.isEmpty()) {
+ return "会";
+ }
+ return text.substring(0, 1);
+ }
+
+ private static String firstNonEmpty(String first, String second, String fallback) {
+ if (!TextUtils.isEmpty(first)) {
+ return first;
+ }
+ if (!TextUtils.isEmpty(second)) {
+ return second;
+ }
+ return fallback;
+ }
}
diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java
new file mode 100644
index 0000000..e162c40
--- /dev/null
+++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java
@@ -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();
+ }
+}
diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java
index a622e56..2c0bb91 100644
--- a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java
@@ -22,8 +22,8 @@ public class DeviceDetailActivity extends BossScreenActivity {
super.onCreate(savedInstanceState);
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
- configureScreen(deviceName == null ? "设备详情" : deviceName, "原生设备详情");
- hideHeaderAction();
+ configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态与绑定项目");
+ setHeaderAction("编辑", v -> openEditDialog());
reload();
}
@@ -56,19 +56,25 @@ public class DeviceDetailActivity extends BossScreenActivity {
}
deviceName = device.optString("name", deviceId);
- configureScreen(deviceName, device.optString("endpoint", "设备详情"));
+ configureScreen(deviceName, "设备状态与绑定项目");
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device);
- appendContent(BossUi.buildListRow(
+ appendContent(BossUi.buildDeviceCard(
this,
- summary.title.isEmpty() ? "设备" : summary.title,
- summary.subtitle,
- summary.meta,
+ WechatSurfaceMapper.toDeviceRow(device),
null,
null
));
-
+ if (summary.meta != null && !summary.meta.isEmpty()) {
+ appendContent(BossUi.buildWechatMenuRow(
+ this,
+ "设备说明",
+ summary.meta,
+ null,
+ null,
+ null
+ ));
+ }
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
- appendContent(BossUi.buildMenuRow(this, "编辑", "修改设备名称、备注和项目列表", null, v -> openEditDialog()));
setRefreshing(false);
}
diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java
index 3613655..a1ae953 100644
--- a/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java
@@ -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", "-"))
diff --git a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java
new file mode 100644
index 0000000..f83a39b
--- /dev/null
+++ b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java
@@ -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 candidates = new ArrayList<>();
+ private final Set selectedProjectIds = new LinkedHashSet<>();
+ private final Set 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 selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId);
+ List nextCandidates = new ArrayList<>(selectableConversations.size());
+ Set 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 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 collectSelectableConversationItems(@Nullable JSONObject conversationsPayload, String sourceProjectId) {
+ List 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 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 selectedProjectIds
+ ) {
+ return !refreshing
+ && !creatingGroupChat
+ && selectedProjectIds != null
+ && !selectedProjectIds.isEmpty();
+ }
+
+ static Set reconcileSelectedProjectIds(
+ @Nullable Set currentSelectedProjectIds,
+ @Nullable Set previousCandidateProjectIds,
+ @Nullable Set nextCandidateProjectIds
+ ) {
+ Set 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;
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java
new file mode 100644
index 0000000..b4d0ad4
--- /dev/null
+++ b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java
@@ -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();
+ }
+}
diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java
index a0ecd4e..48e34a8 100644
--- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java
@@ -32,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;
@@ -55,6 +56,8 @@ public class MainActivity extends AppCompatActivity {
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) {
@@ -107,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);
@@ -124,12 +128,15 @@ public class MainActivity extends AppCompatActivity {
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));
@@ -146,7 +153,7 @@ public class MainActivity extends AppCompatActivity {
}
private void bootstrapSession() {
- showLogin("原生 Android 客户端已启用。点击下方按钮直接进入系统。");
+ showLogin(WechatSurfaceMapper.loginHintText());
if (!apiClient.hasSessionHints()) {
return;
}
@@ -169,7 +176,7 @@ public class MainActivity extends AppCompatActivity {
} catch (Exception ignored) {
// Fall back to login panel.
}
- runOnUiThread(() -> setLoginLoading(false, "点击登录后会直接进入系统。"));
+ runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
});
}
@@ -199,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()) {
@@ -213,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);
@@ -245,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("刷新失败,请稍后重试");
+ });
}
});
}
@@ -264,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);
}
@@ -293,16 +367,19 @@ public class MainActivity extends AppCompatActivity {
switch (activeTab) {
case "devices":
- updateHeader("设备", "生产设备列表");
+ updateHeader("设备", "这里管理已接入设备与账号状态。");
+ configureTopAction("+添加", true);
renderDevicesRoot();
break;
case "me":
- updateHeader("我的", "账号与应用设置");
+ updateHeader("我的", "");
+ configureTopAction("刷新", false);
renderMeRoot();
break;
case "conversations":
default:
- updateHeader("会话", "最近消息");
+ updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle());
+ configureTopAction("刷新", false);
renderConversationsRoot();
break;
}
@@ -311,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() {
@@ -324,8 +402,33 @@ public class MainActivity extends AppCompatActivity {
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.buildHintPill(this, WechatSurfaceMapper.conversationsHintPillText()));
if (conversationsData == null || conversationsData.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
return;
@@ -336,33 +439,22 @@ public class MainActivity extends AppCompatActivity {
if (item == null) continue;
String projectId = item.optString("projectId", "");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
- String badge = row.unreadCount > 0 ? String.valueOf(row.unreadCount) : null;
- screenContent.addView(BossUi.buildListRow(
+ screenContent.addView(BossUi.buildConversationRow(
this,
- row.title.isEmpty() ? "未命名会话" : row.title,
- row.preview.isEmpty() ? "暂无预览" : row.preview,
- row.timeLabel.isEmpty() ? null : row.timeLabel,
- badge,
+ row,
v -> {
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
- openProject(projectId, row.title.isEmpty() ? "未命名会话" : row.title);
+ String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle;
+ openProject(projectId, projectName);
}));
}
}
private void renderDevicesRoot() {
screenContent.removeAllViews();
- screenContent.addView(BossUi.buildMenuRow(
- this,
- "添加设备",
- "通过配对码接入新的生产设备",
- null,
- v -> startActivity(new Intent(this, DeviceEnrollmentActivity.class))
- ));
-
if (devicesData == null || devicesData.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。"));
return;
@@ -373,13 +465,18 @@ public class MainActivity extends AppCompatActivity {
if (item == null) continue;
String deviceId = item.optString("id", "");
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
- screenContent.addView(BossUi.buildListRow(this, row.title, row.subtitle, null, null, v -> {
- if (deviceId.isEmpty()) {
- showMessage("缺少 deviceId");
- return;
- }
- openDevice(deviceId, row.title);
- }));
+ screenContent.addView(BossUi.buildDeviceCard(
+ this,
+ row,
+ v -> {
+ if (deviceId.isEmpty()) {
+ showMessage("缺少 deviceId");
+ return;
+ }
+ openDevice(deviceId, row.title);
+ },
+ null
+ ));
}
}
@@ -391,28 +488,23 @@ public class MainActivity extends AppCompatActivity {
String account = sessionData == null
? apiClient.getAccountLabel()
: sessionData.optString("account", apiClient.getAccountLabel());
- screenContent.addView(BossUi.buildListRow(
+ screenContent.addView(BossUi.buildSimpleProfileHeader(
this,
displayName,
- "账号 " + account,
- null,
- null,
- null
+ "ChatGPT Plus · 主账号",
+ "主控账号已启用安全保护 · " + account
));
- for (String title : WechatSurfaceMapper.rootMeMenuTitles()) {
- screenContent.addView(BossUi.buildMenuRow(
+ for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
+ screenContent.addView(BossUi.buildWechatMenuRow(
this,
- title,
- meDescriptionFor(title),
- meBadgeFor(title),
- v -> openMeEntry(title)
+ 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() {
@@ -450,58 +542,126 @@ 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 title) {
+ private void openMeEntry(String key) {
Intent intent;
- switch (title) {
- case "账号与安全":
+ switch (key) {
+ case "security":
intent = new Intent(this, SecurityActivity.class);
break;
- case "AI 账号":
+ case "ai_accounts":
intent = new Intent(this, AiAccountsActivity.class);
break;
- case "设置":
+ case "settings":
intent = new Intent(this, SettingsActivity.class);
break;
- case "技能":
- intent = new Intent(this, SkillInventoryActivity.class);
+ case "ops":
+ intent = new Intent(this, OpsCenterActivity.class);
break;
- case "关于":
+ case "skills":
+ openSkillInventoryFromMe();
+ return;
+ case "about":
intent = new Intent(this, AboutActivity.class);
break;
default:
- showMessage("暂未接入:" + title);
+ showMessage("暂未接入:" + key);
return;
}
startActivity(intent);
}
- private String meDescriptionFor(String title) {
- switch (title) {
- case "账号与安全":
- return "查看当前会话与登录安全";
- case "AI 账号":
- return "管理主 GPT、备用 GPT 与 API 容灾";
- case "设置":
- return "调整默认首页和提醒偏好";
- case "技能":
- return "按设备查看 Skill 清单";
- case "关于":
- return "查看版本、更新与高级入口";
- default:
- return "";
+ 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 @Nullable String meBadgeFor(String title) {
- if ("关于".equals(title) && otaData != null && otaData.optBoolean("hasOta", false)) {
+ 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;
diff --git a/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java b/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java
index 0491f3d..6a3c3a2 100644
--- a/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java
@@ -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();
- }
}
diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java
index 51a85fe..06f80b0 100644
--- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java
@@ -11,6 +11,8 @@ 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 org.json.JSONArray;
@@ -22,6 +24,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
private String projectId;
private String initialProjectName;
+ private boolean projectIsGroup;
+ private String projectFolderName;
private LinearLayout quickActionsLayout;
private EditText composerInput;
private Button composerSendButton;
@@ -30,6 +34,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private boolean composerSending;
private boolean renderNearBottom;
private boolean renderForcedScrollToBottom;
+ private ActivityResultLauncher conversationInfoLauncher;
@Override
protected int getLayoutResId() {
@@ -45,9 +50,25 @@ public class ProjectDetailActivity extends BossScreenActivity {
composerInput = findViewById(R.id.project_chat_input);
composerSendButton = findViewById(R.id.project_chat_send);
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;
+ configureScreen(updatedTitle, "正在同步项目详情...");
+ }
+ }
+ reload();
+ }
+ );
configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
- hideHeaderAction();
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
composerInput.addTextChangedListener(new TextWatcher() {
@Override
@@ -113,10 +134,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
private void renderProject(JSONObject payload) {
JSONObject project = payload.optJSONObject("project");
JSONArray devices = payload.optJSONArray("devices");
+ JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
String title = project != null ? project.optString("name", "项目详情") : "项目详情";
initialProjectName = title;
- configureScreen(title, "设备:" + joinDeviceNames(devices));
+ projectIsGroup = project != null && project.optBoolean("isGroup", false);
+ projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
+ configureScreen(title, buildProjectSubtitle(projectFolderName, devices));
+ setHeaderAction(WechatSurfaceMapper.conversationInfoActionLabel(), v -> openConversationInfo());
renderQuickActions();
replaceContent();
@@ -242,6 +267,17 @@ public class ProjectDetailActivity extends BossScreenActivity {
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 String joinDeviceNames(@Nullable JSONArray devices) {
if (devices == null || devices.length() == 0) {
return "未绑定设备";
@@ -260,6 +296,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
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;
diff --git a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java
index a155f1c..463a824 100644
--- a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java
@@ -11,7 +11,7 @@ public class SecurityActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- configureScreen("账号与安全", "原生会话与设备安全");
+ configureScreen("账号与安全", "登录会话与设备保护");
reload();
}
@@ -34,16 +34,14 @@ public class SecurityActivity extends BossScreenActivity {
private void renderSecurity(@Nullable JSONObject session) {
replaceContent();
- appendContent(BossUi.buildListRow(
+ appendContent(BossUi.buildSoftPanel(
this,
"当前登录模式",
"当前客户端仍使用快速进入模式。",
- "需要更严格认证时,再切回账号密码或验证码登录。",
- null,
- null
+ "需要更严格认证时,再切回账号密码或验证码登录。"
));
if (session != null) {
- appendContent(BossUi.buildListRow(
+ appendContent(BossUi.buildWechatMenuRow(
this,
"当前会话",
"账号 " + session.optString("account", "-")
diff --git a/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java b/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java
index de98f85..4b211fe 100644
--- a/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java
@@ -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.buildListRow(
+ 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 adapter = new ArrayAdapter<>(
+ this,
+ android.R.layout.simple_spinner_dropdown_item,
+ new String[]{"conversations", "devices", "me"}
+ );
+ preferredEntrySpinner.setAdapter(adapter);
+ }
+
+ replaceContent(BossUi.buildSoftPanel(
this,
"偏好设置",
"调整默认首页和提醒行为。",
- "保存后会直接写入 /api/v1/settings。",
- null,
- null
+ "保存后会直接写入 /api/v1/settings。"
));
- liveUpdatesSwitch = new SwitchCompat(this);
- liveUpdatesSwitch.setText("启用实时刷新");
-
- riskBadgesSwitch = new SwitchCompat(this);
- riskBadgesSwitch.setText("显示风险徽标");
-
- confirmActionsSwitch = new SwitchCompat(this);
- confirmActionsSwitch.setText("危险操作前确认");
-
- preferredEntrySpinner = new Spinner(this);
- ArrayAdapter adapter = new ArrayAdapter<>(
- this,
- android.R.layout.simple_spinner_dropdown_item,
- new String[]{"conversations", "devices", "me"}
- );
- preferredEntrySpinner.setAdapter(adapter);
-
- LinearLayout form = BossUi.buildCard(this, "交互偏好", "切换默认首页与提醒开关。", "保存后立即生效");
- form.addView(liveUpdatesSwitch);
- form.addView(riskBadgesSwitch);
- form.addView(confirmActionsSwitch);
- form.addView(preferredEntrySpinner);
- appendContent(form);
+ 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);
+ }
+ }
}
diff --git a/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java b/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java
index 978ddcb..518c62d 100644
--- a/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java
+++ b/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java
@@ -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,13 +119,11 @@ public class SkillInventoryActivity extends BossScreenActivity {
if (device != null) {
deviceName = device.optString("name", deviceId);
configureScreen("技能", deviceName);
- appendContent(BossUi.buildListRow(
+ appendContent(BossUi.buildSoftPanel(
this,
deviceName,
"当前页按设备查看 Skill 清单。",
- "Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。",
- null,
- null
+ "Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。"
));
}
@@ -85,7 +137,7 @@ public class SkillInventoryActivity extends BossScreenActivity {
if (skill == null) continue;
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
- card.addView(BossUi.buildListRow(
+ card.addView(BossUi.buildWechatMenuRow(
this,
skill.optString("name", "未命名 Skill"),
skill.optString("description", "未提供说明"),
@@ -94,12 +146,11 @@ public class SkillInventoryActivity extends BossScreenActivity {
null,
null
));
- Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句");
+ 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);
diff --git a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
index 0d20044..b44a611 100644
--- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
+++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
@@ -3,6 +3,7 @@ package com.hyzq.boss;
import org.json.JSONObject;
import org.json.JSONArray;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -13,12 +14,13 @@ public final class WechatSurfaceMapper {
"我的"
);
- private static final List ROOT_ME_MENU_TITLES = Arrays.asList(
- "账号与安全",
- "AI 账号",
- "设置",
- "技能",
- "关于"
+ private static final List 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 PROJECT_QUICK_ACTIONS = Arrays.asList(
@@ -37,11 +39,33 @@ public final class WechatSurfaceMapper {
public static ConversationRow toConversationRow(JSONObject item) {
JSONObject source = item == null ? new JSONObject() : item;
+ JSONArray members = source.optJSONArray("groupMembers");
+ List 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("title", source.optString("projectTitle", "")),
- source.optString("preview", ""),
+ 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.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])
);
}
@@ -49,16 +73,20 @@ public final class WechatSurfaceMapper {
JSONObject source = item == null ? new JSONObject() : item;
return new DeviceRow(
source.optString("title", source.optString("name", "")),
- buildSubtitle(source)
+ 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(
- source.optString("title", source.optString("name", "")),
- buildSubtitle(source),
- buildDetailMeta(source)
+ row.title,
+ row.subtitle,
+ mergeDeviceMeta(row.meta, buildDetailMeta(source))
);
}
@@ -67,31 +95,83 @@ public final class WechatSurfaceMapper {
}
public static String[] rootMeMenuTitles() {
- return ROOT_ME_MENU_TITLES.toArray(new String[0]);
+ 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 String advancedEntryTitle() {
- return "高级与调试";
+ 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]);
}
- private static String buildSubtitle(JSONObject source) {
- String statusValue = source.optString("status", "");
- String status;
- if (source.optBoolean("online", false) || "online".equals(statusValue)) {
- status = "在线";
- } else if ("abnormal".equals(statusValue)) {
- status = "异常";
- } else {
- status = "离线";
+ 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 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;
@@ -99,6 +179,103 @@ public final class WechatSurfaceMapper {
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", ""));
@@ -132,26 +309,70 @@ public final class WechatSurfaceMapper {
}
public static final class ConversationRow {
- public final String title;
- public final String preview;
+ 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 title, String preview, String timeLabel, int unreadCount) {
- this.title = title;
- this.preview = preview;
+ 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) {
+ 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;
}
}
@@ -166,4 +387,16 @@ public final class WechatSurfaceMapper {
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;
+ }
+ }
}
diff --git a/android/app/src/main/res/layout/activity_conversation_info.xml b/android/app/src/main/res/layout/activity_conversation_info.xml
new file mode 100644
index 0000000..3f52d82
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_conversation_info.xml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_group_create.xml b/android/app/src/main/res/layout/activity_group_create.xml
new file mode 100644
index 0000000..303fad3
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_group_create.xml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_group_info.xml b/android/app/src/main/res/layout/activity_group_info.xml
new file mode 100644
index 0000000..80624d2
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_group_info.xml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index 992aed9..9df923b 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -16,25 +16,26 @@
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingLeft="24dp"
- android:paddingTop="96dp"
+ android:paddingTop="72dp"
android:paddingRight="24dp"
- android:paddingBottom="32dp">
+ android:paddingBottom="40dp">
@@ -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" />
-
-
-
-
-
-
-
+ android:textSize="14sp" />
+ android:paddingBottom="12dp">
+ android:textSize="12sp"
+ android:visibility="gone" />
@@ -193,9 +170,11 @@
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:background="@color/boss_panel"
+ android:background="@color/boss_bg_app"
android:orientation="vertical"
- android:paddingTop="8dp"
+ android:paddingTop="12dp"
+ android:paddingLeft="0dp"
+ android:paddingRight="0dp"
android:paddingBottom="88dp" />
diff --git a/android/app/src/test/java/com/hyzq/boss/AboutActivityStaleDownloadCleanupTest.java b/android/app/src/test/java/com/hyzq/boss/AboutActivityStaleDownloadCleanupTest.java
new file mode 100644
index 0000000..d28c799
--- /dev/null
+++ b/android/app/src/test/java/com/hyzq/boss/AboutActivityStaleDownloadCleanupTest.java
@@ -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 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;
+ }
+ }
+}
diff --git a/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityTest.java b/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityTest.java
new file mode 100644
index 0000000..f5aaee4
--- /dev/null
+++ b/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityTest.java
@@ -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 filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, "source-1");
+
+ assertEquals(1, filtered.size());
+ assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
+ }
+
+ @Test
+ public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
+ Set previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
+ Set currentSelectedIds = linkedSet("thread-1", "thread-3");
+ Set nextCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
+
+ Set 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 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 linkedSet(String... values) {
+ Set result = new LinkedHashSet<>();
+ for (String value : values) {
+ result.add(value);
+ }
+ return result;
+ }
+
+ private static final class StubJSONObject extends JSONObject {
+ private final java.util.Map 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];
+ }
+ }
+}
diff --git a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
index 215b91f..4a01f14 100644
--- a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
+++ b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
@@ -1,43 +1,102 @@
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_keepsOnlyWechatFields() throws Exception {
+ public void toConversationRow_mapsWechatConversationFieldsFromThreadPayload() throws Exception {
JSONObject item = new StubJSONObject()
.withString("projectTitle", "项目 A")
- .withString("preview", "最近消息预览")
- .withString("latestReplyLabel", "10:24")
+ .withString("threadTitle", "北区试产线回归")
+ .withString("folderLabel", "归档确认")
+ .withString("preview", "旧预览")
+ .withString("lastMessagePreview", "现场摄像头关键帧")
+ .withString("latestReplyLabel", "09:26")
.withInt("unreadCount", 3)
- .withString("deviceNamesPreview", "Mac Studio")
- .withBoolean("contextBudgetIndicator", true);
+ .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("项目 A", row.title);
- assertEquals("最近消息预览", row.preview);
- assertEquals("10:24", row.timeLabel);
+ 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 toDeviceRow_keepsOnlySimpleSubtitle() throws Exception {
+ 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("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
+ assertEquals("额度: 5h 8% · 7d 22%", row.meta);
+ assertEquals("M", row.avatarLabel);
+ assertEquals("online", row.statusKey);
}
@Test
@@ -50,7 +109,9 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
- assertEquals("异常 · 17600003315", row.subtitle);
+ assertEquals("账号: 17600003315", row.subtitle);
+ assertEquals("额度: 暂无 · 状态异常", row.meta);
+ assertEquals("abnormal", row.statusKey);
}
@Test
@@ -61,19 +122,19 @@ public class WechatSurfaceMapperTest {
.withString("account", "17600003315")
.withString("note", "书房主机")
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
- .withArray("projects", "master-agent", "android-app");
+ .withStringArray("projects", "master-agent", "android-app");
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
assertEquals("Mac Studio", summary.title);
- assertEquals("在线 · 17600003315", summary.subtitle);
- assertEquals("书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
+ 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_matchApprovedSimpleMenu() throws Exception {
+ public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
assertArrayEquals(
- new String[]{"账号与安全", "AI 账号", "设置", "技能", "关于"},
+ new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@@ -87,16 +148,112 @@ public class WechatSurfaceMapperTest {
}
@Test
- public void mainPage_doesNotExposeOpsEntry() throws Exception {
+ public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
assertArrayEquals(
- new String[]{"账号与安全", "AI 账号", "设置", "技能", "关于"},
+ new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@Test
- public void advancedEntryTitle_movesOpsOutOfMainMePage() throws Exception {
- assertEquals("高级与调试", WechatSurfaceMapper.advancedEntryTitle());
+ 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
@@ -107,6 +264,16 @@ public class WechatSurfaceMapperTest {
);
}
+ @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(
@@ -115,6 +282,66 @@ public class WechatSurfaceMapperTest {
);
}
+ @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 values = new java.util.HashMap<>();
@@ -133,8 +360,18 @@ public class WechatSurfaceMapperTest {
return this;
}
- StubJSONObject withArray(String key, String... entries) {
- values.put(key, new StubJSONArray(entries));
+ 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;
}
@@ -157,16 +394,22 @@ public class WechatSurfaceMapperTest {
}
@Override
- public org.json.JSONArray optJSONArray(String key) {
+ public JSONObject optJSONObject(String key) {
Object value = values.get(key);
- return value instanceof org.json.JSONArray ? (org.json.JSONArray) value : null;
+ 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 StubJSONArray extends org.json.JSONArray {
+ private static final class StubStringArray extends JSONArray {
private final String[] entries;
- StubJSONArray(String... entries) {
+ StubStringArray(String... entries) {
this.entries = entries == null ? new String[0] : entries;
}
@@ -180,8 +423,28 @@ public class WechatSurfaceMapperTest {
if (index < 0 || index >= entries.length) {
return "";
}
- String value = entries[index];
- return value == null ? "" : value;
+ 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];
}
}
}
diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md
index c295dee..11d3c73 100644
--- a/docs/architecture/ai_handoff_index_cn.md
+++ b/docs/architecture/ai_handoff_index_cn.md
@@ -47,6 +47,9 @@
- `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/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/DeviceDetailActivity.java`:原生设备详情与技能入口
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
@@ -107,16 +110,17 @@
## 5. 当前最重要的产品逻辑
- 一级导航固定:`会话 / 设备 / 我的`
-- `会话` 页按项目渲染聊天列表,主 Agent 永远置顶
-- 单设备项目显示单头像,多设备协作显示群聊式组合头像
-- 非群聊项目显示线程上下文余量圆环
+- `会话` 页当前按“线程 = 聊天窗口”渲染聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式固定在最上面
+- 单线程会话主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标
+- 单设备项目显示单头像,多线程群聊显示群聊式组合头像
- 项目聊天页当前已经改成聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口;线程预算、handoff、运维与转发能力仍保留数据和深层活动页,但不再出现在主聊天面
+- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
+- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
- 主 Agent 项目页会实时吸收 APP 端日志,用于边对话边指导 APK / Web 优化
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
- `版本迭代记录` 只读,由主 Agent 汇总
-- `我的` 根页当前只保留 `账号与安全 / AI 账号 / 设置 / 技能 / 关于`
-- `关于 > 高级与调试` 才进入 `运维对话 / 审计对话 / 修复`
+- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
- `我的 > 技能` 必须按绑定设备展示 Skill,并支持一键复制调用语句
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
@@ -133,7 +137,7 @@
- 邮件:`Postfix + Dovecot`
- Android:`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
- 原生登录恢复:`SharedPreferences + restore token`
-- 当前最新原生 APK:`2.2.1`(`versionCode=10`)
+- 当前最新原生 APK:`2.3.0`(`versionCode=11`)
当前不要误判成已经用了:
diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md
index 5a62beb..aedbfd7 100644
--- a/docs/architecture/api_and_service_inventory_cn.md
+++ b/docs/architecture/api_and_service_inventory_cn.md
@@ -26,6 +26,9 @@
- 当前原生活动页:
- `MainActivity`
- `ProjectDetailActivity`
+ - `ConversationInfoActivity`
+ - `GroupInfoActivity`
+ - `GroupCreateActivity`
- `ProjectGoalsActivity`
- `ProjectVersionsActivity`
- `ProjectForwardActivity`
@@ -41,15 +44,26 @@
- 当前项目聊天页:
- `ProjectDetailActivity` 已改成聊天优先布局
- 主面只保留 `项目目标 / 版本记录`
+ - 右上角会进入微信式 `会话信息 / 群资料`
+ - 单线程会话支持按微信最新逻辑改线程名
+ - 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
- `消息转发 / 线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
- 当前根页导航:
- `MainActivity` 会记住最近一次停留的 `会话 / 设备 / 我的` tab
- 根页返回逻辑已改成“先回会话 tab,再按一次返回进入后台”
+- 当前会话列表:
+ - 已切到“线程 = 会话窗口”
+ - 主标题显示线程名
+ - 第二行显示所属文件夹名
+ - 右下角显示后台活跃数量动态图标
+ - `主 Agent / 审计对话` 已作为普通置顶会话固定在顶部
- 当前 `关于` 页:
- 保留版本与 OTA 操作
- - 新增深层 `高级与调试` 入口,用于进入 `OpsCenterActivity`
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
+- 当前 `我的` 根页:
+ - 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
+ - `运维与修复` 直接进入 `OpsCenterActivity`
- 当前登录:临时免验证,点击登录直接创建最高管理员会话
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
@@ -249,8 +263,12 @@
- 关键字段:
- `conversationType`
- `manualPinned`
- - `contextBudgetIndicator`
- - `mustFinishBeforeCompaction`
+ - `threadTitle`
+ - `folderLabel`
+ - `lastMessagePreview`
+ - `activityIconCount`
+ - `topPinnedLabel`
+ - `groupMembers`
#### `POST /api/v1/conversations/[projectId]/actions`
@@ -282,6 +300,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`
diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md
index caba540..80ea836 100644
--- a/docs/architecture/current_runtime_and_deploy_status_cn.md
+++ b/docs/architecture/current_runtime_and_deploy_status_cn.md
@@ -1,6 +1,6 @@
# Boss 当前运行与部署状态
-更新时间:`2026-03-27`
+更新时间:`2026-03-28`
## 1. 本地状态
@@ -89,9 +89,11 @@ cd /Users/kris/code/boss
- 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
- UI 外壳已收口为真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部一级导航固定在视口底部,背景图按手机 viewport 全屏 cover,WebView 不再显示外层圆角矩形预览壳
-- 原生 Android 当前也和这套产品方向对齐:`会话 / 设备 / 我的` 为固定底部 tab,一级面尽量维持微信式简单列表和聊天优先,不再把线程预算 / 运维面板放在主聊天页或一级我的页
+- 原生 Android 当前也和这套产品方向对齐:`会话 / 设备 / 我的` 为固定底部 tab,一级面维持微信式简单列表和聊天优先;`主 Agent / 审计对话` 以普通置顶会话样式固定在会话首页顶部
+- 会话列表当前已切到“线程 = 聊天窗口”:主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会渲染成多个独立聊天窗口
+- 项目详情页右上角当前会进入微信式会话信息页:单线程会话支持改名和发起群聊,群聊会进入群资料页并支持改群名
- 会话页、设备页、技能页和项目详情页当前都通过 `/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` 当前已支持登录态分页查询
@@ -109,14 +111,15 @@ 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.2.1`(`versionCode=10`)
+- 当前最新 release 构建版本:`2.3.0`(`versionCode=11`)
- 当前 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.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表
- `2.2.1` 已继续补齐原生交互细节:聊天页会即时显示本地“发送中”气泡,并且只在用户接近底部或本次发送主动触发时自动滚到底;关于页会显示 OTA 下载进度 / 重试 / 安装授权提示,离开后再回来仍会恢复本地下载状态;根 tab 会记住最近一次用户停留页,并把一级页返回逻辑收成“先回会话 tab,再按一次返回进入后台”
+- `2.3.0` 已把原生会话模型切到“线程 = 聊天窗口”:补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
## 2. 服务器状态
diff --git a/public/downloads/boss-android-latest-aab.json b/public/downloads/boss-android-latest-aab.json
index fc60050..fc32a5c 100644
--- a/public/downloads/boss-android-latest-aab.json
+++ b/public/downloads/boss-android-latest-aab.json
@@ -1,11 +1,11 @@
{
"artifactType": "aab",
- "fileName": "boss-android-v2.2.1-release.aab",
- "urlPath": "/downloads/boss-android-v2.2.1-release.aab",
- "sizeBytes": 2864252,
- "updatedAt": "2026-03-27T07:43:43Z",
- "sha256": "3f9fec6fa5279fa703436dac6e9d7d0390bf52265c5529306e1e02fab919dc5c",
- "versionName": "2.2.1",
- "versionCode": 10,
+ "fileName": "boss-android-v2.3.0-release.aab",
+ "urlPath": "/downloads/boss-android-v2.3.0-release.aab",
+ "sizeBytes": 2885958,
+ "updatedAt": "2026-03-27T21:18:55Z",
+ "sha256": "3888548f27fa6880201ebe9ffc4efedc5e35012d78c65175f2c43316503cc9bc",
+ "versionName": "2.3.0",
+ "versionCode": 11,
"buildFlavor": "release"
}
diff --git a/public/downloads/boss-android-latest.aab b/public/downloads/boss-android-latest.aab
index 5ead325..0c38e1e 100644
Binary files a/public/downloads/boss-android-latest.aab and b/public/downloads/boss-android-latest.aab differ
diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk
index 3cd25ea..d84551b 100644
Binary files a/public/downloads/boss-android-latest.apk and b/public/downloads/boss-android-latest.apk differ
diff --git a/public/downloads/boss-android-latest.json b/public/downloads/boss-android-latest.json
index 515388b..12b883d 100644
--- a/public/downloads/boss-android-latest.json
+++ b/public/downloads/boss-android-latest.json
@@ -1,10 +1,10 @@
{
- "fileName": "boss-android-v2.2.1-release.apk",
+ "fileName": "boss-android-v2.3.0-release.apk",
"urlPath": "/api/v1/user/ota/package",
- "sizeBytes": 3043187,
- "updatedAt": "2026-03-27T07:43:25Z",
- "sha256": "86b2ef0d455fb289ce7eafdec75f5006c241f4db5bff94b7ccb36d4998ff40ef",
- "versionName": "2.2.1",
- "versionCode": 10,
+ "sizeBytes": 3063299,
+ "updatedAt": "2026-03-27T21:18:45Z",
+ "sha256": "c0f23f3834c874c793a043b52392136cbb568528e1c57dc56358696ed7095e35",
+ "versionName": "2.3.0",
+ "versionCode": 11,
"buildFlavor": "release"
}
diff --git a/public/downloads/boss-android-v2.3.0-release.aab b/public/downloads/boss-android-v2.3.0-release.aab
new file mode 100644
index 0000000..0c38e1e
Binary files /dev/null and b/public/downloads/boss-android-v2.3.0-release.aab differ
diff --git a/public/downloads/boss-android-v2.3.0-release.apk b/public/downloads/boss-android-v2.3.0-release.apk
new file mode 100644
index 0000000..d84551b
Binary files /dev/null and b/public/downloads/boss-android-v2.3.0-release.apk differ
diff --git a/src/app/api/v1/projects/[projectId]/group-chat/route.ts b/src/app/api/v1/projects/[projectId]/group-chat/route.ts
new file mode 100644
index 0000000..e625e35
--- /dev/null
+++ b/src/app/api/v1/projects/[projectId]/group-chat/route.ts
@@ -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 },
+ );
+ }
+}
diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts
index 2160d8a..9f85dbc 100644
--- a/src/app/api/v1/projects/[projectId]/messages/route.ts
+++ b/src/app/api/v1/projects/[projectId]/messages/route.ts
@@ -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" },
diff --git a/src/app/api/v1/projects/[projectId]/participants/route.ts b/src/app/api/v1/projects/[projectId]/participants/route.ts
new file mode 100644
index 0000000..cc17eee
--- /dev/null
+++ b/src/app/api/v1/projects/[projectId]/participants/route.ts
@@ -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,
+ });
+}
diff --git a/src/app/api/v1/projects/[projectId]/rename/route.ts b/src/app/api/v1/projects/[projectId]/rename/route.ts
new file mode 100644
index 0000000..a37c405
--- /dev/null
+++ b/src/app/api/v1/projects/[projectId]/rename/route.ts
@@ -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 },
+ );
+ }
+}
diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts
index e265030..d91f9c8 100644
--- a/src/lib/boss-data.ts
+++ b/src/lib/boss-data.ts
@@ -130,6 +130,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 +159,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 +722,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 +762,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 +847,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 +1348,140 @@ function nowIso() {
return new Date().toISOString();
}
+function normalizeThreadMeta(
+ raw: Partial | 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,
+ 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();
+ 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;
+ },
+) {
+ 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, latestActivityAt?: string) {
+ return latestIsoTimestamp(
+ project.updatedAt,
+ project.lastMessageAt,
+ project.threadMeta.updatedAt,
+ latestActivityAt,
+ );
+}
+
+function latestIsoTimestamp(...values: Array) {
+ 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(value: T[] | undefined, fallback: T[]) {
return Array.isArray(value) ? value : fallback;
}
@@ -1597,7 +1812,17 @@ function normalizeMessage(raw: Partial): Message {
function normalizeProject(raw: Partial, 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 +1845,17 @@ function normalizeProject(raw: Partial, 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 | undefined): BossState {
@@ -2253,11 +2488,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 +2506,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 +4061,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();
+ 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;
diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts
index 0956003..45a9a6f 100644
--- a/src/lib/boss-projections.ts
+++ b/src/lib/boss-projections.ts
@@ -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);
});
}