feat: restore wechat thread ui and group chat

This commit is contained in:
kris
2026-03-28 05:21:44 +08:00
parent afa7e79ad2
commit f0735b31e5
41 changed files with 4091 additions and 578 deletions

View File

@@ -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 / 审计对话` 普通置顶会话化
## 本地启动

View File

@@ -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"
}

View File

@@ -32,6 +32,9 @@
<activity android:name=".ProjectVersionsActivity" android:exported="false" />
<activity android:name=".ProjectForwardActivity" android:exported="false" />
<activity android:name=".ThreadDetailActivity" android:exported="false" />
<activity android:name=".ConversationInfoActivity" android:exported="false" />
<activity android:name=".GroupInfoActivity" android:exported="false" />
<activity android:name=".GroupCreateActivity" android:exported="false" />
<activity android:name=".DeviceDetailActivity" android:exported="false" />
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" />
<activity android:name=".SkillInventoryActivity" android:exported="false" />

View File

@@ -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);

View File

@@ -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 账号")

View File

@@ -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);

View File

@@ -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;
}
}

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

@@ -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<Intent> 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;

View File

@@ -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", "-")

View File

@@ -15,13 +15,15 @@ public class SettingsActivity extends BossScreenActivity {
private SwitchCompat riskBadgesSwitch;
private SwitchCompat confirmActionsSwitch;
private Spinner preferredEntrySpinner;
private boolean settingsLoaded = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("设置", "原生偏好配置");
configureScreen("设置", "默认首页与提醒偏好");
setHeaderAction("保存", v -> saveSettings());
buildForm();
buildFormContent();
updateSaveAvailability();
reload();
}
@@ -36,48 +38,52 @@ public class SettingsActivity extends BossScreenActivity {
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
settingsLoaded = false;
updateSaveAvailability();
replaceContent(BossUi.buildEmptyCard(this, "设置加载失败:" + error.getMessage()));
});
}
});
}
private void buildForm() {
replaceContent(BossUi.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<String> 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<String> 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);
}
}
}

View File

@@ -45,8 +45,15 @@ public class SkillInventoryActivity extends BossScreenActivity {
}
private String resolveTargetDeviceId() throws Exception {
if (deviceId != null && !deviceId.isEmpty()) {
return deviceId;
String explicitDeviceId = deviceId;
String boundDeviceId = null;
BossApiClient.ApiResponse settingsResponse = apiClient.getSettings();
if (settingsResponse.ok()) {
JSONObject user = settingsResponse.json.optJSONObject("user");
if (user != null) {
String candidate = user.optString("boundDeviceId", "");
boundDeviceId = candidate.isEmpty() ? null : candidate;
}
}
BossApiClient.ApiResponse response = apiClient.getDevices();
if (!response.ok()) throw new IllegalStateException(response.message());
@@ -54,7 +61,54 @@ public class SkillInventoryActivity extends BossScreenActivity {
if (devices == null || devices.length() == 0) {
throw new IllegalStateException("NO_DEVICE");
}
return devices.optJSONObject(0).optString("id");
return chooseTargetDeviceId(explicitDeviceId, boundDeviceId, apiClient.getAccountLabel(), devices);
}
private static String chooseTargetDeviceId(
@Nullable String explicitDeviceId,
@Nullable String boundDeviceId,
String account,
JSONArray devices
) {
String explicitMatch = findDeviceId(devices, explicitDeviceId);
if (explicitMatch != null) {
return explicitMatch;
}
String boundMatch = findDeviceId(devices, boundDeviceId);
if (boundMatch != null) {
return boundMatch;
}
for (int i = 0; i < devices.length(); i++) {
JSONObject device = devices.optJSONObject(i);
if (device == null) continue;
if (account.equals(device.optString("account", ""))) {
return device.optString("id", "");
}
}
if (devices.length() == 1) {
JSONObject onlyDevice = devices.optJSONObject(0);
if (onlyDevice != null) {
return onlyDevice.optString("id", "");
}
}
JSONObject fallback = devices.optJSONObject(0);
return fallback == null ? "" : fallback.optString("id", "");
}
private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) {
if (candidateDeviceId == null || candidateDeviceId.isEmpty()) {
return null;
}
for (int i = 0; i < devices.length(); i++) {
JSONObject device = devices.optJSONObject(i);
if (device == null) continue;
if (candidateDeviceId.equals(device.optString("id", ""))) {
return candidateDeviceId;
}
}
return null;
}
private void renderSkills(JSONObject payload) {
@@ -65,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);

View File

@@ -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<String> ROOT_ME_MENU_TITLES = Arrays.asList(
"账号与安全",
"AI 账号",
"设置",
"技能",
"关于"
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"),
new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"),
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
);
private static final List<String> PROJECT_QUICK_ACTIONS = Arrays.asList(
@@ -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<GroupAvatarMember> groupAvatarMembers = new ArrayList<>();
if (members != null) {
for (int i = 0; i < members.length(); i++) {
JSONObject member = members.optJSONObject(i);
if (member == null) continue;
groupAvatarMembers.add(new GroupAvatarMember(
member.optString("threadId", ""),
member.optString("avatar", ""),
member.optString("title", "")
));
}
}
JSONObject avatar = source.optJSONObject("avatar");
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
return new ConversationRow(
source.optString("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> 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;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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">
<TextView
android:layout_width="80dp"
android:layout_height="80dp"
android:background="@drawable/bg_tab_active"
android:layout_width="72dp"
android:layout_height="72dp"
android:background="@drawable/bg_secondary_button"
android:gravity="center"
android:text="B"
android:textColor="@color/boss_green"
android:textSize="32sp"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:id="@+id/login_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Boss 原生控制台"
android:layout_marginTop="22dp"
android:text=""
android:textColor="@color/boss_text_primary"
android:textSize="30sp"
android:textStyle="bold" />
@@ -43,38 +44,12 @@
android:id="@+id/login_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginTop="12dp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:text="原生 Android 客户端已启用。点击下方按钮直接进入系统。"
android:text=""
android:textColor="@color/boss_text_muted"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:background="@drawable/bg_list_row"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="当前临时模式"
android:textColor="@color/boss_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:lineSpacingExtra="4dp"
android:text="1. 这是原生 Android Activity不再打开 WebView。\n2. 登录暂时不做验证,点击按钮会直接进入最高管理员会话。\n3. 会话 / 设备 / 我的三栏都直接调用现有 Boss API。"
android:textColor="@color/boss_text_primary"
android:textSize="14sp" />
</LinearLayout>
android:textSize="14sp" />
<ProgressBar
android:id="@+id/login_progress"
@@ -87,11 +62,11 @@
android:id="@+id/login_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginTop="22dp"
android:background="@drawable/bg_primary_button"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="登录"
android:text=""
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textSize="18sp"
@@ -113,9 +88,9 @@
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="16dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="14dp">
android:paddingBottom="12dp">
<Button
android:id="@+id/back_button"
@@ -153,9 +128,10 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="原生 Android 客户端,直接消费 Boss API。"
android:text=""
android:textColor="@color/boss_text_muted"
android:textSize="13sp" />
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
<Button
@@ -163,11 +139,12 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_secondary_button"
android:paddingLeft="16dp"
android:paddingTop="9dp"
android:paddingRight="16dp"
android:paddingBottom="9dp"
android:text="刷新"
android:minWidth="0dp"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp"
android:text="刷"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
@@ -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" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

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

View File

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

View File

@@ -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<String, Object> 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];
}
}
}

View File

@@ -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`
当前不要误判成已经用了:

View File

@@ -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`

View File

@@ -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 全屏 coverWebView 不再显示外层圆角矩形预览壳
- 原生 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. 服务器状态

View File

@@ -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"
}

View File

@@ -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"
}

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ThreadConversationMeta> | undefined,
project: { id: string; name: string; isGroup: boolean; updatedAt: string },
fallback?: ThreadConversationMeta,
): ThreadConversationMeta {
return {
projectId: raw?.projectId ?? project.id,
threadId: raw?.threadId ?? `thread-${project.id}`,
threadDisplayName: raw?.threadDisplayName ?? project.name,
folderName: raw?.folderName ?? fallback?.folderName ?? (project.isGroup ? "群聊" : project.name),
activityIconCount: Math.max(1, raw?.activityIconCount ?? fallback?.activityIconCount ?? (project.isGroup ? 2 : 1)),
updatedAt: raw?.updatedAt ?? project.updatedAt ?? nowIso(),
codexThreadRef: raw?.codexThreadRef,
codexFolderRef: raw?.codexFolderRef,
};
}
function normalizeGroupMember(
raw: Partial<GroupConversationMember>,
fallbackProjectId: string,
fallbackThreadMeta: ThreadConversationMeta,
): GroupConversationMember {
return {
projectId: raw.projectId ?? fallbackProjectId,
deviceId: raw.deviceId ?? "",
threadId: raw.threadId ?? fallbackThreadMeta.threadId,
threadDisplayName: raw.threadDisplayName ?? fallbackThreadMeta.threadDisplayName,
folderName: raw.folderName ?? fallbackThreadMeta.folderName,
};
}
function dedupeStrings(values: string[]) {
return [...new Set(values.filter((value) => Boolean(value)))];
}
function dedupeGroupMembers(members: GroupConversationMember[]) {
const seen = new Set<string>();
const deduped: GroupConversationMember[] = [];
for (const member of members) {
const key = `${member.projectId}:${member.deviceId}:${member.threadId}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(member);
}
return deduped;
}
function buildLegacyGroupMembers(
projectId: string,
deviceIds: string[],
threadMeta: ThreadConversationMeta,
) {
return dedupeStrings(deviceIds).map((deviceId, index) => ({
projectId,
deviceId,
threadId:
index === 0 ? threadMeta.threadId : `${threadMeta.threadId}:${slugify(deviceId)}`,
threadDisplayName: threadMeta.threadDisplayName,
folderName: threadMeta.folderName,
}));
}
function normalizeProjectConversationShape(
project: Project,
options?: {
allowedDeviceIds?: Set<string>;
},
) {
const allowedDeviceIds = options?.allowedDeviceIds;
const normalizedThreadMeta = {
...project.threadMeta,
projectId: project.id,
};
const normalizedExplicitMembers = dedupeGroupMembers(
project.groupMembers.map((member) =>
normalizeGroupMember(member, project.id, normalizedThreadMeta),
),
);
const hasExplicitGroupMembers = normalizedExplicitMembers.length > 0;
const legacyGroupRequested = !hasExplicitGroupMembers && project.isGroup;
const resolvedGroupMembers = hasExplicitGroupMembers
? normalizedExplicitMembers
: legacyGroupRequested
? buildLegacyGroupMembers(project.id, project.deviceIds, normalizedThreadMeta)
: [];
const filteredGroupMembers = allowedDeviceIds
? resolvedGroupMembers.filter((member) => allowedDeviceIds.has(member.deviceId))
: resolvedGroupMembers;
if (filteredGroupMembers.length > 0) {
project.isGroup = true;
project.groupMembers = dedupeGroupMembers(filteredGroupMembers);
project.deviceIds = dedupeStrings(project.groupMembers.map((member) => member.deviceId));
project.threadMeta = {
...normalizedThreadMeta,
activityIconCount: Math.max(1, project.groupMembers.length),
};
return project;
}
project.isGroup = false;
project.groupMembers = [];
project.deviceIds = allowedDeviceIds
? project.deviceIds.filter((deviceId) => allowedDeviceIds.has(deviceId))
: project.deviceIds;
project.threadMeta = {
...normalizedThreadMeta,
activityIconCount: Math.max(1, normalizedThreadMeta.activityIconCount ?? 1),
};
return project;
}
function resolveProjectUpdatedAt(project: Pick<Project, "updatedAt" | "lastMessageAt" | "threadMeta">, latestActivityAt?: string) {
return latestIsoTimestamp(
project.updatedAt,
project.lastMessageAt,
project.threadMeta.updatedAt,
latestActivityAt,
);
}
function latestIsoTimestamp(...values: Array<string | undefined>) {
let latestValue: string | undefined;
let latestTime = 0;
for (const value of values) {
const valueTime = messageTimeValue(value);
if (valueTime > latestTime) {
latestTime = valueTime;
latestValue = value;
}
}
return latestValue ?? nowIso();
}
function ensureArray<T>(value: T[] | undefined, fallback: T[]) {
return Array.isArray(value) ? value : fallback;
}
@@ -1597,7 +1812,17 @@ function normalizeMessage(raw: Partial<Message>): Message {
function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
const base = fallback ?? cloneInitialState().projects[0];
return {
const projectId = raw.id ?? base.id;
const projectName = raw.name ?? base.name;
const projectUpdatedAt = latestIsoTimestamp(raw.updatedAt, raw.lastMessageAt, base.updatedAt, base.lastMessageAt);
const threadMetaFallback = fallback?.id === projectId ? fallback.threadMeta : undefined;
const threadMeta = normalizeThreadMeta(raw.threadMeta, {
id: projectId,
name: projectName,
isGroup: raw.isGroup ?? base.isGroup ?? false,
updatedAt: projectUpdatedAt,
}, threadMetaFallback);
const project: Project = {
...base,
...raw,
pinned: raw.pinned ?? base.pinned,
@@ -1620,7 +1845,17 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
summary: version.summary ?? "",
createdAt: version.createdAt ?? nowIso(),
})),
threadMeta,
createdByAgent: raw.createdByAgent ?? false,
collaborationMode: raw.collaborationMode ?? "development",
approvalState: raw.approvalState ?? "not_required",
};
project.groupMembers = ensureArray(raw.groupMembers, []).map((member) =>
normalizeGroupMember(member, projectId, project.threadMeta),
);
normalizeProjectConversationShape(project);
project.updatedAt = resolveProjectUpdatedAt(project, project.threadMeta.updatedAt);
return project;
}
function normalizeState(raw: Partial<BossState> | undefined): BossState {
@@ -2253,11 +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<string>();
for (const projectId of requestedProjectIds) {
if (seenProjectIds.has(projectId)) {
continue;
}
seenProjectIds.add(projectId);
const memberProject = state.projects.find((item) => item.id === projectId);
if (!memberProject) {
throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND");
}
memberProjects.push(memberProject);
}
if (memberProjects.length < 2) {
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
}
const now = nowIso();
const projectId = randomToken("project");
const threadId = randomToken("thread");
const threadDisplayName = source.threadMeta.threadDisplayName ?? source.name;
const folderName = source.threadMeta.folderName ?? (source.isGroup ? "群聊" : source.name);
const groupMembers = memberProjects.map((memberProject) => ({
projectId: memberProject.id,
deviceId: memberProject.deviceIds[0] ?? memberProject.id,
threadId: memberProject.threadMeta.threadId,
threadDisplayName: memberProject.threadMeta.threadDisplayName,
folderName: memberProject.threadMeta.folderName,
}));
const nextProject = normalizeProject({
id: projectId,
name: threadDisplayName,
pinned: false,
systemPinned: false,
deviceIds: dedupeStrings(groupMembers.map((member) => member.deviceId)),
preview: `已创建群聊《${threadDisplayName}`,
updatedAt: now,
lastMessageAt: now,
isGroup: true,
unreadCount: 0,
riskLevel: source.riskLevel,
threadMeta: {
projectId,
threadId,
threadDisplayName,
folderName,
activityIconCount: Math.max(1, memberProjects.length),
updatedAt: now,
},
groupMembers,
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
messages: [
{
id: randomToken("msg"),
sender: "master",
senderLabel: input.createdBy || "群聊创建",
body: `已由 ${input.createdBy || "系统"} 创建群聊《${threadDisplayName}》。`,
sentAt: now,
kind: "text",
},
],
goals: [],
versions: [],
});
state.projects.unshift(nextProject);
return nextProject;
});
publishBossEvent("project.messages.updated", { projectId: project.id });
publishBossEvent("conversation.updated", { projectId: project.id });
return project;
}
export async function appendProjectMessage(payload: {
projectId: string;
sender?: MessageSender;

View File

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