From 449f84fcbc0b22ddc686d59cfc94fcb836b62be1 Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 18 Apr 2026 04:51:50 +0800 Subject: [PATCH] feat: refine mobile master agent sync and chat rendering --- README.md | 1 + android/app/src/main/AndroidManifest.xml | 4 +- .../com/hyzq/boss/AiAccountsActivity.java | 1949 ++++++++++++----- .../java/com/hyzq/boss/BossApiClient.java | 20 + .../java/com/hyzq/boss/BossApplication.java | 13 + .../main/java/com/hyzq/boss/BossMarkdown.java | 23 + .../com/hyzq/boss/BossRealtimeClient.java | 10 + .../src/main/java/com/hyzq/boss/BossUi.java | 6 + .../main/java/com/hyzq/boss/MainActivity.java | 82 +- .../com/hyzq/boss/MasterAgentModePresets.java | 121 + .../com/hyzq/boss/ProjectDetailActivity.java | 328 ++- .../com/hyzq/boss/ProjectGoalsActivity.java | 29 + .../hyzq/boss/ProjectVersionsActivity.java | 6 +- .../main/res/layout/activity_project_chat.xml | 31 +- .../src/main/res/layout/activity_screen.xml | 10 +- android/app/src/main/res/values/styles.xml | 3 +- .../com/hyzq/boss/AiAccountsActivityTest.java | 706 ++++-- .../boss/BossApiClientDispatchPlansTest.java | 21 + .../java/com/hyzq/boss/BossMarkdownTest.java | 28 + .../com/hyzq/boss/BossRealtimeClientTest.java | 11 + .../com/hyzq/boss/BossUiFormCellTest.java | 28 + .../MainActivityConversationSearchTest.java | 103 +- .../hyzq/boss/MainActivityRealtimeTest.java | 21 + ...jectDetailActivityMasterAgentMenuTest.java | 61 + .../ProjectDetailActivityRealtimeTest.java | 48 + .../boss/ProjectDetailActivityUiTest.java | 224 +- .../hyzq/boss/ProjectGoalsActivityUiTest.java | 38 +- .../boss/ProjectVersionsActivityTest.java | 6 +- .../boss/ProjectVersionsActivityUiTest.java | 14 +- .../current_runtime_and_deploy_status_cn.md | 1 + eslint.config.mjs | 1 + src/app/api/v1/accounts/[accountId]/route.ts | 27 +- .../v1/accounts/onboard/aliyun-qwen/route.ts | 3 + .../v1/accounts/onboard/openai-api/route.ts | 3 + src/app/api/v1/accounts/route.ts | 27 +- .../api/v1/accounts/validate-draft/route.ts | 59 + .../[projectId]/agent-controls/route.ts | 16 + .../v1/projects/[projectId]/messages/route.ts | 157 +- .../conversations/[projectId]/goals/page.tsx | 37 + .../[projectId]/versions/page.tsx | 4 +- src/components/app-ui.tsx | 91 +- .../master-agent-prompt-memory-client.tsx | 10 +- src/lib/boss-data.ts | 610 ++++-- src/lib/boss-master-agent.ts | 1216 +++++++++- src/lib/chat-markdown.ts | 108 + src/lib/master-agent-model-options.ts | 13 + src/lib/thread-execution-conflict.ts | 32 + tests/ai-account-routes.test.ts | 292 +++ tests/ai-account-validation.test.ts | 187 ++ tests/chat-markdown.test.ts | 52 + tests/device-execution-conflict.test.ts | 51 +- tests/master-agent-chat-controls.test.ts | 36 + tests/master-agent-config-resolution.test.ts | 4 +- tests/master-agent-message-queue.test.ts | 508 +++++ tests/master-agent-model-options.test.ts | 17 + tests/master-agent-openai-fallback.test.ts | 81 + .../master-agent-thread-status-prompt.test.ts | 88 + tests/project-goal-events.test.ts | 59 + tests/project-scoped-realtime-refresh.test.ts | 32 +- tests/single-thread-message-execution.test.ts | 357 ++- tests/thread-status-sync.test.ts | 2 +- 61 files changed, 7051 insertions(+), 1075 deletions(-) create mode 100644 android/app/src/main/java/com/hyzq/boss/BossApplication.java create mode 100644 android/app/src/main/java/com/hyzq/boss/MasterAgentModePresets.java create mode 100644 android/app/src/test/java/com/hyzq/boss/BossUiFormCellTest.java create mode 100644 src/app/api/v1/accounts/validate-draft/route.ts create mode 100644 src/lib/chat-markdown.ts create mode 100644 src/lib/master-agent-model-options.ts create mode 100644 tests/ai-account-routes.test.ts create mode 100644 tests/chat-markdown.test.ts create mode 100644 tests/master-agent-model-options.test.ts diff --git a/README.md b/README.md index 52140e7..66ca8e0 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Android APK: - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` - 当前最新 release 构建版本:`2.5.11`(`versionCode=24`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` +- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一只使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机 - Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect :5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳 - Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 Wi‑Fi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效 - 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ea6bbfe..9ad5b9f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,12 +5,14 @@ + android:theme="@style/AppTheme" + android:forceDarkAllowed="false"> openAccountEditor(null, null)); + currentRole = resolveRequestedRole(); + configureScreen(resolveScreenTitle(), resolveScreenSubtitle()); replaceContent(); reload(); } @@ -51,9 +90,24 @@ public class AiAccountsActivity extends BossScreenActivity { setRefreshing(true); executor.execute(() -> { try { - BossApiClient.ApiResponse response = apiClient.getAccounts(); - if (!response.ok()) throw new IllegalStateException(response.message()); - runOnUiThread(() -> renderAccounts(response.json)); + BossApiClient.ApiResponse accountsResponse = apiClient.getAccounts(); + if (!accountsResponse.ok()) { + throw new IllegalStateException(accountsResponse.message()); + } + JSONObject controls = null; + try { + BossApiClient.ApiResponse controlsResponse = apiClient.getProjectAgentControls("master-agent"); + if (controlsResponse.ok()) { + controls = controlsResponse.json.optJSONObject("controls"); + } + } catch (Exception ignored) { + controls = null; + } + JSONObject finalControls = controls; + runOnUiThread(() -> { + applyMasterAgentControls(finalControls); + renderAccounts(accountsResponse.json); + }); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -63,49 +117,604 @@ public class AiAccountsActivity extends BossScreenActivity { }); } + private void applyMasterAgentControls(@Nullable JSONObject controls) { + currentMasterAgentModelOverride = normalizeControlValue(controls == null ? null : controls.optString("modelOverride", null)); + currentMasterAgentReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("reasoningEffortOverride", null)); + currentFastModelOverride = normalizeControlValue(controls == null ? null : controls.optString("fastModelOverride", null)); + currentDeepModelOverride = normalizeControlValue(controls == null ? null : controls.optString("deepModelOverride", null)); + } + private void renderAccounts(JSONObject payload) { JSONArray accounts = payload.optJSONArray("accounts"); - JSONObject activeIdentity = payload.optJSONObject("activeIdentity"); + replaceContent(); - appendContent(BossUi.buildWechatMenuRow( - this, - "AI 账号", - "这里统一管理主 GPT、备用 GPT 与 API 容灾账号。", - "OpenAI API 可以在手机直接登录;Master Codex Node 仍然在绑定设备上完成登录。", - null, - null - )); - appendContent(buildActiveIdentityCard(activeIdentity)); - appendContent(buildOnboardingEntrySection()); - appendContent(buildAccountsSection(accounts)); + if (currentRole == null) { + appendContent(buildOverviewSection(accounts)); + } else { + appendContent(buildApiSection( + isPrimaryRole(currentRole) ? "主要API配置" : "备用API配置", + isPrimaryRole(currentRole) + ? "主链路只在这里配置 OAuth 登录或 API 接入。" + : "主链路异常时自动切到这里,不抢占当前主控。", + accounts, + currentRole + )); + } setRefreshing(false); } + @Nullable + private String resolveRequestedRole() { + Intent intent = getIntent(); + if (intent == null) { + return null; + } + String requestedRole = intent.getStringExtra(EXTRA_ACCOUNT_ROLE); + if (ROLE_PRIMARY.equals(requestedRole)) { + return ROLE_PRIMARY; + } + if (ROLE_BACKUP.equals(requestedRole) || ROLE_API_FALLBACK.equals(requestedRole)) { + return ROLE_BACKUP; + } + return null; + } + + private String resolveScreenTitle() { + if (currentRole == null) { + return "AI 账号"; + } + return isPrimaryRole(currentRole) ? "主要API配置" : "备用API配置"; + } + + private String resolveScreenSubtitle() { + if (currentRole == null) { + return "主要API与备用API"; + } + return "OAuth 登录与 API 接入"; + } + + private LinearLayout buildOverviewSection(@Nullable JSONArray accounts) { + LinearLayout section = new LinearLayout(this); + section.setOrientation(LinearLayout.VERTICAL); + section.addView(BossUi.buildWechatMenuRow( + this, + "主要API配置", + overviewSummaryForRole(accounts, ROLE_PRIMARY), + "主链路入口", + null, + v -> openRoleConfigScreen(ROLE_PRIMARY) + )); + section.addView(BossUi.buildWechatMenuRow( + this, + "备用API配置", + overviewSummaryForRole(accounts, ROLE_BACKUP), + "备用链路入口", + null, + v -> openRoleConfigScreen(ROLE_BACKUP) + )); + return section; + } + + private String overviewSummaryForRole(@Nullable JSONArray accounts, String targetRole) { + int count = countAccountsForRole(accounts, targetRole); + if (count <= 0) { + return "暂未配置,点进去添加。"; + } + return "已配置 " + count + " 条,点进去查看和编辑。"; + } + + private int countAccountsForRole(@Nullable JSONArray accounts, String targetRole) { + if (accounts == null) { + return 0; + } + int count = 0; + for (int index = 0; index < accounts.length(); index += 1) { + JSONObject account = accounts.optJSONObject(index); + if (account == null) { + continue; + } + if (matchesTargetRole(account.optString("role", ""), targetRole)) { + count += 1; + } + } + return count; + } + + private void openRoleConfigScreen(String role) { + Intent intent = new Intent(this, AiAccountsActivity.class); + intent.putExtra(EXTRA_ACCOUNT_ROLE, normalizeRole(role)); + startActivity(intent); + } + + private LinearLayout buildMasterAgentSection(@Nullable JSONArray accounts, @Nullable JSONObject activeIdentity) { + LinearLayout section = new LinearLayout(this); + section.setOrientation(LinearLayout.VERTICAL); + section.addView(BossUi.buildWechatMenuRow( + this, + "主Agent", + "当前主控身份、设备节点与主 Agent 对话入口。", + "优先使用绑定设备上的 Codex 节点。", + null, + null + )); + section.addView(buildActiveIdentityCard(activeIdentity)); + + JSONObject nodeAccount = findFirstAccount(accounts, PROVIDER_MASTER_CODEX_NODE, ROLE_PRIMARY); + section.addView(BossUi.buildWechatMenuRow( + this, + "绑定设备节点", + nodeAccount == null ? "把这台电脑上的 Codex 节点接入主 Agent。" : "继续维护当前主 Agent 绑定设备。", + nodeAccount == null ? "登录发生在绑定设备,不在手机里直接完成。" : providerMeta(nodeAccount), + nodeAccount == null ? "去绑定" : "编辑", + v -> openMasterNodeEditor(nodeAccount) + )); + if (nodeAccount == null) { + section.addView(BossUi.buildEmptyCard(this, "还没有绑定可接管主 Agent 的设备节点。")); + } else { + section.addView(buildAccountCard(nodeAccount)); + } + return section; + } + + private LinearLayout buildApiSection( + String title, + String subtitle, + @Nullable JSONArray accounts, + String targetRole + ) { + LinearLayout section = new LinearLayout(this); + section.setOrientation(LinearLayout.VERTICAL); + section.addView(BossUi.buildWechatMenuRow( + this, + "当前使用方式", + currentMethodSummary(accounts, targetRole), + currentMethodMeta(targetRole), + "配置", + v -> openCurrentMethodConfig(targetRole, accounts) + )); + if (isPrimaryRole(targetRole)) { + section.addView(BossUi.buildWechatMenuRow( + this, + "主Agent模式", + "当前:" + MasterAgentModePresets.describeCurrentMode( + currentMasterAgentModelOverride, + currentMasterAgentReasoningEffortOverride, + currentFastModelOverride, + currentDeepModelOverride + ), + "切换后会和主Agent对话框保持同步。", + "切换", + v -> showMasterAgentModePicker() + )); + section.addView(BossUi.buildWechatMenuRow( + this, + "快速反应模型", + "当前:" + MasterAgentModePresets.resolveFastModel(currentFastModelOverride), + "快速问答默认使用低推理强度。", + "配置", + v -> showMasterAgentModeModelPicker(true) + )); + section.addView(BossUi.buildWechatMenuRow( + this, + "深度思考模型", + "当前:" + MasterAgentModePresets.resolveDeepModel(currentDeepModelOverride), + "复杂任务默认使用高推理强度。", + "配置", + v -> showMasterAgentModeModelPicker(false) + )); + } + section.addView(BossUi.buildWechatMenuRow( + this, + "OAuth 登录", + isPrimaryRole(targetRole) ? "设置主要 OAuth 登录。" : "设置备用 OAuth 登录。", + configuredMethodAccountsSummary(accounts, targetRole, true), + null, + v -> openRoleProviderChooser(targetRole, true) + )); + section.addView(BossUi.buildWechatMenuRow( + this, + "API 接入", + isPrimaryRole(targetRole) ? "设置主要 API 接入。" : "设置备用 API 接入。", + configuredMethodAccountsSummary(accounts, targetRole, false), + null, + v -> openRoleProviderChooser(targetRole, false) + )); + return section; + } + + private String currentMethodSummary(@Nullable JSONArray accounts, String targetRole) { + JSONObject account = findCurrentMethodAccount(accounts, targetRole); + if (account == null) { + return "当前使用:未配置"; + } + String provider = account.optString("provider", ""); + String methodType = isOauthProvider(provider) ? "OAuth 登录" : "API 接入"; + String currentModel = account.optString("model", "").trim(); + return "当前使用:" + providerLabelForProvider(provider) + + "\n接入类型:" + methodType + + "\n当前模型:" + (currentModel.isEmpty() ? "未设置" : currentModel); + } + + private String currentMethodMeta(String targetRole) { + if (isPrimaryRole(targetRole)) { + return "点进可编辑当前账号"; + } + return "点进可编辑当前使用账号"; + } + + private String configuredMethodAccountsSummary(@Nullable JSONArray accounts, String targetRole, boolean oauthOnly) { + if (accounts == null) { + return "已配置:暂无"; + } + List providerLabels = new ArrayList<>(); + for (int index = 0; index < accounts.length(); index += 1) { + JSONObject account = accounts.optJSONObject(index); + if (account == null) { + continue; + } + if (!matchesTargetRole(account.optString("role", ""), targetRole)) { + continue; + } + String provider = account.optString("provider", ""); + boolean matchesMethod = oauthOnly ? isOauthProvider(provider) : isApiProvider(provider); + if (!matchesMethod) { + continue; + } + String providerLabel = providerLabelForProvider(provider); + if (!providerLabel.isEmpty() && !providerLabels.contains(providerLabel)) { + providerLabels.add(providerLabel); + } + } + if (providerLabels.isEmpty()) { + return "已配置:暂无"; + } + return "已配置:" + android.text.TextUtils.join(" / ", providerLabels); + } + + @Nullable + private JSONObject findCurrentMethodAccount(@Nullable JSONArray accounts, String targetRole) { + if (accounts == null) { + return null; + } + JSONObject firstMatched = null; + for (int index = 0; index < accounts.length(); index += 1) { + JSONObject account = accounts.optJSONObject(index); + if (account == null) { + continue; + } + if (!matchesTargetRole(account.optString("role", ""), targetRole)) { + continue; + } + String provider = account.optString("provider", ""); + if (!isOauthProvider(provider) && !isApiProvider(provider)) { + continue; + } + if (firstMatched == null) { + firstMatched = account; + } + if (account.optBoolean("isActive")) { + return account; + } + } + return firstMatched; + } + + private void openCurrentMethodConfig(String targetRole, @Nullable JSONArray accounts) { + JSONObject currentAccount = findCurrentMethodAccount(accounts, targetRole); + if (currentAccount == null) { + final String[] items = new String[]{"OAuth 登录", "API 接入"}; + new AlertDialog.Builder(this) + .setTitle("当前使用方式") + .setItems(items, (dialog, which) -> openRoleProviderChooser(targetRole, which == 0)) + .show(); + return; + } + + openAccountEditor(currentAccount, null); + } + + private LinearLayout buildOauthEntryGroup(@Nullable JSONArray accounts, String targetRole) { + LinearLayout group = new LinearLayout(this); + group.setOrientation(LinearLayout.VERTICAL); + group.addView(BossUi.buildWechatMenuRow( + this, + "OAuth 登录", + isPrimaryRole(targetRole) + ? "谷歌登录和 ChatGPT登录都会记到主链路。" + : "OAuth 备用链路会保存为备用账号,便于后续切换。", + "先网页登录,再回这里保存账号信息。", + null, + null + )); + group.addView(buildGroupMarker( + "可编辑配置", + "下面这些入口都可以直接点进去配置或修改。" + )); + + for (ProviderOption option : OAUTH_OPTIONS) { + JSONObject existing = findFirstAccount(accounts, option.provider, targetRole); + group.addView(BossUi.buildWechatMenuRow( + this, + option.label, + option.summary, + existing == null + ? providerRoleHint(targetRole) + : "已保存,状态见下方摘要卡", + existing == null ? "配置" : "编辑", + v -> openOauthAccountDialog(targetRole, option.provider, existing) + )); + } + + boolean hasOauthAccount = appendRoleAccounts(group, accounts, targetRole, new AccountFilter() { + @Override + public boolean matches(String provider) { + return isOauthProvider(provider); + } + }); + if (!hasOauthAccount) { + group.addView(BossUi.buildEmptyCard(this, isPrimaryRole(targetRole) + ? "还没有配置主链路 OAuth 账号。" + : "还没有配置备用 OAuth 账号。")); + } + return group; + } + + private LinearLayout buildApiEntryGroup(@Nullable JSONArray accounts, String targetRole) { + LinearLayout group = new LinearLayout(this); + group.setOrientation(LinearLayout.VERTICAL); + group.addView(BossUi.buildWechatMenuRow( + this, + "API 接入", + isPrimaryRole(targetRole) + ? "主链路可直接接兼容 API。" + : "备用链路可接兼容 API 做兜底。", + "支持阿里、Minimax、GLM、环宇智擎和自定义。", + null, + null + )); + group.addView(buildGroupMarker( + "可编辑配置", + "下面这些入口都可以直接点进去配置或修改。" + )); + + for (ProviderOption option : API_OPTIONS) { + JSONObject existing = findFirstAccount(accounts, option.provider, targetRole); + String subtitle = option.summary; + if (defaultApiBaseUrlForProvider(option.provider).isEmpty() && PROVIDER_CUSTOM_API.equals(option.provider)) { + subtitle = "自定义地址和模型,适合对接私有代理或网关。"; + } + group.addView(BossUi.buildWechatMenuRow( + this, + option.label, + subtitle, + existing == null + ? defaultApiBaseUrlForProvider(option.provider) + : "已保存,状态见下方摘要卡", + existing == null ? "配置" : "编辑", + v -> openApiAccountDialog(targetRole, option.provider, existing, null) + )); + } + + boolean hasApiAccount = appendRoleAccounts(group, accounts, targetRole, new AccountFilter() { + @Override + public boolean matches(String provider) { + return isApiProvider(provider); + } + }); + if (!hasApiAccount) { + group.addView(BossUi.buildEmptyCard(this, isPrimaryRole(targetRole) + ? "还没有配置主链路 API。" + : "还没有配置备用 API。")); + } + return group; + } + + private boolean appendRoleAccounts( + LinearLayout parent, + @Nullable JSONArray accounts, + String targetRole, + AccountFilter filter + ) { + if (accounts == null) { + return false; + } + boolean matched = false; + for (int index = 0; index < accounts.length(); index += 1) { + JSONObject account = accounts.optJSONObject(index); + if (account == null) { + continue; + } + if (!matchesTargetRole(account.optString("role", ""), targetRole)) { + continue; + } + if (!filter.matches(account.optString("provider", ""))) { + continue; + } + if (!matched) { + parent.addView(buildGroupMarker( + "当前已保存", + "下面这些是只读状态摘要,操作按钮在卡片底部。" + )); + } + parent.addView(buildAccountCard(account)); + matched = true; + } + return matched; + } + + @Nullable + private JSONObject findFirstAccount(@Nullable JSONArray accounts, String provider, String targetRole) { + if (accounts == null) { + return null; + } + for (int index = 0; index < accounts.length(); index += 1) { + JSONObject account = accounts.optJSONObject(index); + if (account == null || !provider.equals(account.optString("provider", ""))) { + continue; + } + if (matchesTargetRole(account.optString("role", ""), targetRole)) { + return account; + } + } + return null; + } + + private boolean matchesTargetRole(String accountRole, String targetRole) { + if (isPrimaryRole(targetRole)) { + return ROLE_PRIMARY.equals(accountRole); + } + return ROLE_BACKUP.equals(accountRole) || ROLE_API_FALLBACK.equals(accountRole); + } + + private boolean isPrimaryRole(String role) { + return ROLE_PRIMARY.equals(role); + } + + private boolean isOauthProvider(String provider) { + return PROVIDER_GOOGLE_OAUTH.equals(provider) || PROVIDER_CHATGPT_OAUTH.equals(provider); + } + + private boolean isApiProvider(String provider) { + return PROVIDER_OPENAI_API.equals(provider) + || PROVIDER_ALIYUN_QWEN_API.equals(provider) + || PROVIDER_MINIMAX_API.equals(provider) + || PROVIDER_GLM_API.equals(provider) + || PROVIDER_HYZQ_API.equals(provider) + || PROVIDER_CUSTOM_API.equals(provider); + } + + private String defaultApiBaseUrlForProvider(String provider) { + if (PROVIDER_OPENAI_API.equals(provider)) { + return OPENAI_API_BASE_URL; + } + if (PROVIDER_ALIYUN_QWEN_API.equals(provider)) { + return ALIYUN_QWEN_API_BASE_URL; + } + if (PROVIDER_MINIMAX_API.equals(provider)) { + return MINIMAX_API_BASE_URL; + } + if (PROVIDER_GLM_API.equals(provider)) { + return GLM_API_BASE_URL; + } + if (PROVIDER_HYZQ_API.equals(provider)) { + return HYZQ_API_BASE_URL; + } + return ""; + } + + private String defaultModelForProvider(String provider) { + if (PROVIDER_ALIYUN_QWEN_API.equals(provider)) { + return "qwen3.5-plus"; + } + if (PROVIDER_MINIMAX_API.equals(provider)) { + return "MiniMax-M1"; + } + if (PROVIDER_GLM_API.equals(provider)) { + return "glm-4.5"; + } + return DEFAULT_MASTER_MODEL; + } + + private String providerLabelForProvider(String provider) { + if (PROVIDER_MASTER_CODEX_NODE.equals(provider)) { + return "主Agent 节点"; + } + if (PROVIDER_GOOGLE_OAUTH.equals(provider)) { + return "谷歌登录"; + } + if (PROVIDER_CHATGPT_OAUTH.equals(provider)) { + return "ChatGPT登录"; + } + if (PROVIDER_OPENAI_API.equals(provider)) { + return "OpenAI API"; + } + if (PROVIDER_ALIYUN_QWEN_API.equals(provider)) { + return "阿里"; + } + if (PROVIDER_MINIMAX_API.equals(provider)) { + return "Minimax"; + } + if (PROVIDER_GLM_API.equals(provider)) { + return "GLM"; + } + if (PROVIDER_HYZQ_API.equals(provider)) { + return "环宇智擎"; + } + if (PROVIDER_CUSTOM_API.equals(provider)) { + return "自定义"; + } + return "未识别提供方"; + } + + private String roleLabelForRole(String role) { + return isPrimaryRole(role) ? "主链路" : "备用链路"; + } + + private String providerRoleHint(String role) { + return isPrimaryRole(role) ? "会作为主链路账号保存" : "会作为备用链路保存"; + } + + private String defaultLabelForRole(String role) { + return isPrimaryRole(role) ? "主要API" : "备用API"; + } + + private String defaultDisplayNameForProvider(String role, String provider) { + return providerLabelForProvider(provider) + (isPrimaryRole(role) ? " 主链路账号" : " 备用账号"); + } + + private String resolveApiBaseUrl(@Nullable JSONObject account) { + if (account == null) { + return ""; + } + String configured = account.optString("apiBaseUrl", "").trim(); + if (!configured.isEmpty()) { + return configured; + } + return defaultApiBaseUrlForProvider(account.optString("provider", "")); + } + + private String providerMeta(JSONObject account) { + String statusLabel = account.optString("statusLabel", account.optString("status", "-")); + String providerLabel = account.optString("providerLabel", providerLabelForProvider(account.optString("provider", ""))); + StringBuilder meta = new StringBuilder(); + meta.append(account.optString("roleLabel", roleLabelForRole(account.optString("role", ROLE_PRIMARY)))); + meta.append(" · ").append(providerLabel); + meta.append(" · ").append(statusLabel); + if (account.optBoolean("apiKeyConfigured")) { + meta.append(" · 已配置 Key"); + } + String apiBaseUrl = resolveApiBaseUrl(account); + if (!apiBaseUrl.isEmpty()) { + meta.append(" · ").append(apiBaseUrl); + } + return meta.toString(); + } + private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) { + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); if (activeIdentity == null) { - LinearLayout empty = new LinearLayout(this); - empty.setOrientation(LinearLayout.VERTICAL); - empty.addView(BossUi.buildWechatMenuRow( + card.addView(BossUi.buildWechatMenuRow( this, "当前主控身份", - "当前没有可用账号。", - "请先新增或启用一个账号。", + "当前没有可用主控。", + "请先绑定主Agent节点,或在 API 设置里配置主链路账号。", null, null )); - return empty; + return card; } + + String providerLabel = activeIdentity.optString( + "providerLabel", + providerLabelForProvider(activeIdentity.optString("provider", "")) + ); String subtitle = activeIdentity.optString("label", "AI 账号") + " · " + activeIdentity.optString("displayName", "-"); String meta = activeIdentity.optString("roleLabel", "-") - + " · " + activeIdentity.optString("providerLabel", "-") + + " · " + providerLabel + " · " + activeIdentity.optString("statusLabel", "-"); - String note = activeIdentity.optString("note", ""); - String activeAccountId = activeIdentity.optString("accountId", ""); - boolean canGenerate = activeIdentity.optBoolean("canGenerate", false); - LinearLayout card = new LinearLayout(this); - card.setOrientation(LinearLayout.VERTICAL); card.addView(BossUi.buildWechatMenuRow( this, "当前主控身份", @@ -115,6 +724,7 @@ public class AiAccountsActivity extends BossScreenActivity { null )); + String note = activeIdentity.optString("note", "").trim(); if (!note.isEmpty()) { card.addView(BossUi.buildWechatMenuRow( this, @@ -126,437 +736,170 @@ public class AiAccountsActivity extends BossScreenActivity { )); } + String activeAccountId = activeIdentity.optString("accountId", ""); if (!activeAccountId.isEmpty()) { Button validate = BossUi.buildMiniActionButton(this, "校验主控", false); validate.setOnClickListener(v -> validateAccount(activeAccountId)); - - Button testMasterAgent = BossUi.buildMiniActionButton(this, "测试主 Agent 对话", canGenerate); - testMasterAgent.setEnabled(canGenerate); - testMasterAgent.setOnClickListener(v -> openMasterAgentConversation()); - - card.addView(BossUi.buildInlineActionRow(this, validate, testMasterAgent)); + Button conversation = BossUi.buildMiniActionButton( + this, + "测试主 Agent 对话", + activeIdentity.optBoolean("canGenerate", false) + ); + conversation.setEnabled(activeIdentity.optBoolean("canGenerate", false)); + conversation.setOnClickListener(v -> openMasterAgentConversation()); + card.addView(BossUi.buildInlineActionRow(this, validate, conversation)); } - return card; } - private LinearLayout buildAccountsSection(@Nullable JSONArray accounts) { - LinearLayout section = new LinearLayout(this); - section.setOrientation(LinearLayout.VERTICAL); - - section.addView(BossUi.buildWechatMenuRow( - this, - "账号列表", - accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点开可编辑,按钮可激活、校验或删除。", - null, - null, - null - )); - - if (accounts == null || accounts.length() == 0) { - section.addView(BossUi.buildEmptyCard(this, "尚未配置任何 AI 账号。")); - return section; - } - - for (int i = 0; i < accounts.length(); i++) { - JSONObject account = accounts.optJSONObject(i); - if (account == null) continue; - section.addView(buildAccountCard(account)); - } - return section; - } - - private LinearLayout buildOnboardingEntrySection() { - LinearLayout section = new LinearLayout(this); - section.setOrientation(LinearLayout.VERTICAL); - - section.addView(BossUi.buildWechatMenuRow( - this, - "登录 OpenAI 平台账号", - "先打开 OpenAI 登录页,再回 APP 完成接入。", - "成功后会立即设为当前主控。", - "推荐", - v -> openOpenAiOnboardingScreen() - )); - - section.addView(BossUi.buildWechatMenuRow( - this, - "接入阿里百炼备用账号", - "把阿里百炼 Qwen 兼容接口接成主 Agent 的备用链路。", - "建议模型:qwen3.5-plus 或 qwen3.5-flash。", - null, - v -> openAliyunQwenOnboardingDialog() - )); - - section.addView(BossUi.buildWechatMenuRow( - this, - "绑定电脑上的 Codex 节点", - "把这台 Mac 上的 Codex / ChatGPT Plus 节点接回主 Agent。", - "登录发生在绑定设备上。", - null, - v -> openMasterNodeOnboardingDialog() - )); - - return section; - } - - private void openOpenAiOnboardingScreen() { - refreshOnResume = true; - Intent intent = new Intent(this, OpenAiOnboardingActivity.class); - intent.putExtra(OpenAiOnboardingActivity.EXTRA_AUTO_OPEN_LOGIN, true); - startActivity(intent); - } - private LinearLayout buildAccountCard(JSONObject account) { - String statusLabel = account.optString("statusLabel", account.optString("status", "-")); - String meta = account.optString("roleLabel", "-") - + " · " + account.optString("providerLabel", "-") - + " · " + statusLabel - + (account.optBoolean("apiKeyConfigured") ? " · 已配置 Key" : ""); - StringBuilder subtitle = new StringBuilder(account.optString("displayName", "-")); - if (!account.optString("accountIdentifier").isEmpty()) { - subtitle.append(" · ").append(account.optString("accountIdentifier", "-")); + StringBuilder body = new StringBuilder(); + body.append(account.optString("label", "未命名账号")); + body.append("\n").append(account.optString("displayName", "-")); + String accountIdentifier = account.optString("accountIdentifier", "").trim(); + String nodeLabel = account.optString("nodeLabel", "").trim(); + String apiBaseUrl = resolveApiBaseUrl(account); + if (!accountIdentifier.isEmpty()) { + body.append("\n账号标识:").append(accountIdentifier); } - if (!account.optString("nodeLabel").isEmpty()) { - subtitle.append(" · ").append(account.optString("nodeLabel", "-")); + if (!nodeLabel.isEmpty()) { + body.append("\n节点名称:").append(nodeLabel); + } + if (!apiBaseUrl.isEmpty()) { + body.append("\nAPI 地址:").append(apiBaseUrl); } LinearLayout card = new LinearLayout(this); card.setOrientation(LinearLayout.VERTICAL); - card.addView(BossUi.buildWechatMenuRow( + String meta = providerMeta(account); + if (account.optBoolean("isActive")) { + meta = meta + " · 当前"; + } + card.addView(BossUi.buildSoftPanel( this, - account.optString("label", "未命名账号"), - subtitle.toString(), - meta, - account.optBoolean("isActive") ? "当前" : null, - v -> openAccountEditor(account, null) + "只读状态", + body.toString(), + meta )); - Button activate = BossUi.buildMiniActionButton(this, account.optBoolean("isActive") ? "当前主控" : "设为当前", !account.optBoolean("isActive")); + Button edit = BossUi.buildMiniActionButton(this, "编辑账号", false); + edit.setOnClickListener(v -> openAccountEditor(account, null)); + + Button activate = BossUi.buildMiniActionButton( + this, + account.optBoolean("isActive") ? "当前主控" : "设为当前", + !account.optBoolean("isActive") + ); activate.setEnabled(!account.optBoolean("isActive")); activate.setOnClickListener(v -> activateAccount(account)); - Button loginGuide = null; - if ("master_codex_node".equals(account.optString("provider"))) { - loginGuide = BossUi.buildMiniActionButton(this, "登录指引", false); - loginGuide.setOnClickListener(v -> showMasterNodeLoginGuide(account)); - } - Button validate = BossUi.buildMiniActionButton(this, "校验连接", false); validate.setOnClickListener(v -> validateAccount(account)); Button delete = BossUi.buildMiniActionButton(this, "删除账号", false); delete.setOnClickListener(v -> confirmDeleteAccount(account)); - card.addView(loginGuide == null - ? BossUi.buildInlineActionRow(this, activate, validate, delete) - : BossUi.buildInlineActionRow(this, activate, loginGuide, validate, delete)); + if (PROVIDER_MASTER_CODEX_NODE.equals(account.optString("provider", ""))) { + Button loginGuide = BossUi.buildMiniActionButton(this, "登录指引", false); + loginGuide.setOnClickListener(v -> showMasterNodeLoginGuide(account)); + card.addView(BossUi.buildInlineActionRow(this, edit, activate, loginGuide, validate, delete)); + } else { + card.addView(BossUi.buildInlineActionRow(this, edit, activate, validate, delete)); + } return card; } + private LinearLayout buildGroupMarker(String title, String helper) { + return BossUi.buildSoftPanel(this, title, helper, null); + } + private void showMasterNodeLoginGuide(JSONObject account) { - String nodeLabel = account.optString("nodeLabel"); - if (nodeLabel == null || nodeLabel.trim().isEmpty()) { - nodeLabel = account.optString("nodeId"); + String nodeLabel = account.optString("nodeLabel", "").trim(); + if (nodeLabel.isEmpty()) { + nodeLabel = account.optString("nodeId", "").trim(); } - if (nodeLabel == null || nodeLabel.trim().isEmpty()) { + if (nodeLabel.isEmpty()) { nodeLabel = "绑定设备"; } - - String message = "主 GPT 不在手机里直接登录。\n\n" - + "请到绑定设备 " + nodeLabel + " 上打开 Codex / ChatGPT Plus 会话完成登录。\n" - + "登录完成后,回到这里点“校验连接”,确认主 Agent relay 已经接通。"; - new AlertDialog.Builder(this) - .setTitle("主 GPT 登录指引") - .setMessage(message) + .setTitle("主Agent 登录指引") + .setMessage("主 Agent 不在手机里直接登录。\n\n请到绑定设备 " + nodeLabel + " 上完成 Codex / ChatGPT 登录,再回这里点“校验连接”。") .setPositiveButton("知道了", null) .show(); } - private void openOpenAiOnboardingDialog() { - final EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false); - labelInput.setText("主 GPT"); - final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false); - displayNameInput.setText("OpenAI 平台账号"); - final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false); - final EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false); - modelInput.setText("gpt-5.4"); - final EditText apiKeyInput = BossUi.buildInput(this, "OpenAI API Key", false); - apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); - - LinearLayout form = new LinearLayout(this); - form.setOrientation(LinearLayout.VERTICAL); - form.addView(BossUi.buildFormCell(this, "标签", "建议使用 主 GPT", labelInput)); - form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput)); - form.addView(BossUi.buildFormCell(this, "账号标识", "可填邮箱、账号名或自定义备注", accountIdentifierInput)); - form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput)); - form.addView(BossUi.buildFormCell(this, "API Key", "填写后会直接登录并设为当前主控", apiKeyInput)); + private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) { + if (existing == null) { + openEntryChooser(); + return; + } + String provider = existing.optString("provider", ""); + String role = normalizeRole(existing.optString("role", ROLE_PRIMARY)); + if (PROVIDER_MASTER_CODEX_NODE.equals(provider)) { + openMasterNodeEditor(existing); + return; + } + if (isOauthProvider(provider)) { + openOauthAccountDialog(role, provider, existing); + return; + } + openApiAccountDialog(role, provider, existing, apiKeyHint); + } + private void openEntryChooser() { + final String[] items = new String[] { + "绑定主Agent节点", + "新增主链路 OAuth", + "新增主链路 API", + "新增备用 API" + }; new AlertDialog.Builder(this) - .setTitle("登录 OpenAI 平台账号") - .setMessage("手机端直接输入 OpenAI API Key,登录成功后立即设为当前主控。") - .setView(form) - .setNegativeButton("取消", null) - .setPositiveButton("登录", (dialog, which) -> submitOpenAiOnboarding( - labelInput.getText().toString().trim(), - displayNameInput.getText().toString().trim(), - accountIdentifierInput.getText().toString().trim(), - modelInput.getText().toString().trim(), - apiKeyInput.getText().toString().trim() - )) + .setTitle("新增 AI 账号") + .setItems(items, (dialog, which) -> { + if (which == 0) { + openMasterNodeEditor(null); + } else if (which == 1) { + openRoleProviderChooser(ROLE_PRIMARY, true); + } else if (which == 2) { + openRoleProviderChooser(ROLE_PRIMARY, false); + } else { + openRoleProviderChooser(ROLE_BACKUP, false); + } + }) .show(); } - private void openMasterNodeOnboardingDialog() { - final EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false); - labelInput.setText("主 GPT"); + private void openRoleProviderChooser(String role, boolean oauth) { + ProviderOption[] options = oauth ? OAUTH_OPTIONS : API_OPTIONS; + String[] labels = new String[options.length]; + for (int index = 0; index < options.length; index += 1) { + labels[index] = options[index].label; + } + new AlertDialog.Builder(this) + .setTitle((isPrimaryRole(role) ? "主链路" : "备用链路") + (oauth ? " · OAuth 登录" : " · API 接入")) + .setItems(labels, (dialog, which) -> { + ProviderOption option = options[which]; + if (oauth) { + openOauthAccountDialog(role, option.provider, null); + } else { + openApiAccountDialog(role, option.provider, null, null); + } + }) + .show(); + } + + private void openMasterNodeEditor(@Nullable JSONObject existing) { + final EditText labelInput = BossUi.buildInput(this, "标签,例如 主要API", false); final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false); - displayNameInput.setText("绑定电脑上的 Codex 节点"); final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false); final EditText nodeIdInput = BossUi.buildInput(this, "节点 ID", false); final EditText nodeLabelInput = BossUi.buildInput(this, "节点名称", false); final EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false); - modelInput.setText("gpt-5.4"); - - LinearLayout form = new LinearLayout(this); - form.setOrientation(LinearLayout.VERTICAL); - 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", "本机 Codex 节点的唯一标识", nodeIdInput)); - form.addView(BossUi.buildFormCell(this, "节点名称", "例如 Mac Studio", nodeLabelInput)); - form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput)); - - new AlertDialog.Builder(this) - .setTitle("绑定电脑上的 Codex 节点") - .setMessage("主 GPT 不在手机里直接登录,请在绑定设备上的 Codex / ChatGPT Plus 会话里登录后再回来校验。") - .setView(form) - .setNegativeButton("取消", null) - .setPositiveButton("绑定", (dialog, which) -> submitMasterNodeOnboarding( - labelInput.getText().toString().trim(), - displayNameInput.getText().toString().trim(), - accountIdentifierInput.getText().toString().trim(), - nodeIdInput.getText().toString().trim(), - nodeLabelInput.getText().toString().trim(), - modelInput.getText().toString().trim() - )) - .show(); - } - - private void openAliyunQwenOnboardingDialog() { - final EditText labelInput = BossUi.buildInput(this, "标签,例如 备用 GPT", false); - labelInput.setText("备用 GPT"); - final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false); - displayNameInput.setText("阿里百炼备用账号"); - final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false); - final EditText apiKeyInput = BossUi.buildInput(this, "阿里百炼 API Key", false); - apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); - final AliyunModelSelection modelSelection = buildAliyunQwenModelSelection("qwen3.5-plus"); - - LinearLayout form = new LinearLayout(this); - form.setOrientation(LinearLayout.VERTICAL); - form.addView(BossUi.buildFormCell(this, "标签", "建议使用 备用 GPT", labelInput)); - form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput)); - form.addView(BossUi.buildFormCell(this, "账号标识", "可填账号名或自定义备注", accountIdentifierInput)); - form.addView(BossUi.buildFormCell(this, "模型", "预设 qwen3.5-plus / qwen3.5-flash,不适用时切换自定义模型。", modelSelection.container)); - form.addView(BossUi.buildFormCell(this, "API Key", "填写后会保存为备用链路,不会抢占当前主控", apiKeyInput)); - - new AlertDialog.Builder(this) - .setTitle("接入阿里百炼备用账号") - .setMessage("接入成功后,这个账号会作为主 Agent 的备用模型链路,在主节点离线或失败时自动接管。") - .setView(form) - .setNegativeButton("取消", null) - .setPositiveButton("接入", (dialog, which) -> submitAliyunQwenOnboarding( - labelInput.getText().toString().trim(), - displayNameInput.getText().toString().trim(), - accountIdentifierInput.getText().toString().trim(), - modelSelection.resolveModel(), - apiKeyInput.getText().toString().trim() - )) - .show(); - } - - private void submitOpenAiOnboarding( - String label, - String displayName, - String accountIdentifier, - String model, - String apiKey - ) { - if (label.isEmpty() || displayName.isEmpty() || apiKey.isEmpty()) { - showMessage("标签、显示名称和 API Key 不能为空"); - return; - } - setRefreshing(true); - executor.execute(() -> { - try { - JSONObject payload = new JSONObject(); - payload.put("label", label); - payload.put("displayName", displayName); - payload.put("accountIdentifier", accountIdentifier); - payload.put("model", model); - payload.put("apiKey", apiKey); - payload.put("enabled", true); - payload.put("setActive", true); - payload.put("provider", "openai_api"); - payload.put("role", "primary"); - - BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload); - if (!response.ok()) throw new IllegalStateException(response.message()); - runOnUiThread(() -> { - showMessage("OpenAI 平台账号已登录,并设为当前主控。"); - reload(); - }); - } catch (Exception error) { - runOnUiThread(() -> { - setRefreshing(false); - String detail = error.getMessage(); - showMessage(detail == null || detail.trim().isEmpty() - ? "OpenAI 平台账号登录失败,请稍后重试。" - : "OpenAI 平台账号登录失败:" + detail); - }); - } - }); - } - - private void submitMasterNodeOnboarding( - String label, - String displayName, - String accountIdentifier, - String nodeId, - String nodeLabel, - String model - ) { - if (label.isEmpty() || displayName.isEmpty() || nodeId.isEmpty()) { - showMessage("标签、显示名称和节点 ID 不能为空"); - return; - } - setRefreshing(true); - executor.execute(() -> { - try { - JSONObject payload = new JSONObject(); - payload.put("label", label); - payload.put("displayName", displayName); - payload.put("accountIdentifier", accountIdentifier); - payload.put("nodeId", nodeId); - payload.put("nodeLabel", nodeLabel); - payload.put("model", model); - payload.put("enabled", true); - payload.put("setActive", true); - payload.put("provider", "master_codex_node"); - payload.put("role", "primary"); - - BossApiClient.ApiResponse response = apiClient.onboardMasterNodeAccount(payload); - if (!response.ok()) throw new IllegalStateException(response.message()); - - String accountId = extractAccountId(response.json); - if (accountId.isEmpty()) { - runOnUiThread(() -> { - showMessage("Master Codex Node 已绑定,并设为当前主控。"); - reload(); - }); - return; - } - - BossApiClient.ApiResponse validation = apiClient.validateAccount(accountId); - runOnUiThread(() -> { - showMessage(validation.ok() - ? validation.message() - : "绑定完成,但校验失败:" + validation.message()); - reload(); - }); - } catch (Exception error) { - runOnUiThread(() -> { - setRefreshing(false); - showMessage("绑定失败:" + error.getMessage()); - }); - } - }); - } - - private void submitAliyunQwenOnboarding( - String label, - String displayName, - String accountIdentifier, - String model, - String apiKey - ) { - if (label.isEmpty() || displayName.isEmpty() || apiKey.isEmpty()) { - showMessage("标签、显示名称和 API Key 不能为空"); - return; - } - if (model.isEmpty()) { - showMessage("模型不能为空"); - return; - } - setRefreshing(true); - executor.execute(() -> { - try { - JSONObject payload = new JSONObject(); - payload.put("label", label); - payload.put("displayName", displayName); - payload.put("accountIdentifier", accountIdentifier); - payload.put("model", model); - payload.put("apiKey", apiKey); - - BossApiClient.ApiResponse response = apiClient.onboardAliyunQwenAccount(payload); - if (!response.ok()) throw new IllegalStateException(response.message()); - runOnUiThread(() -> { - showMessage("阿里百炼备用账号已接入。"); - reload(); - }); - } catch (Exception error) { - runOnUiThread(() -> { - setRefreshing(false); - String detail = error.getMessage(); - showMessage(detail == null || detail.trim().isEmpty() - ? "阿里百炼备用账号接入失败,请稍后重试。" - : "阿里百炼备用账号接入失败:" + detail); - }); - } - }); - } - - private String extractAccountId(JSONObject json) { - if (json == null) { - return ""; - } - String accountId = json.optString("accountId", ""); - if (!accountId.isEmpty()) { - return accountId; - } - JSONObject account = json.optJSONObject("account"); - if (account != null) { - return account.optString("accountId", ""); - } - return ""; - } - - private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) { - final android.widget.EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false); - final android.widget.EditText displayNameInput = BossUi.buildInput(this, "显示名称", false); - final android.widget.EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 邮箱 / 登录名", false); - final android.widget.EditText nodeIdInput = BossUi.buildInput(this, "节点 ID", false); - final android.widget.EditText nodeLabelInput = BossUi.buildInput(this, "节点名称", false); - final android.widget.EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false); - final AliyunModelSelection aliyunModelSelection = buildAliyunQwenModelSelection( - existing == null ? "qwen3.5-plus" : existing.optString("model", "") - ); - final android.widget.EditText apiKeyInput = BossUi.buildInput(this, "API Key", false); - final android.widget.EditText loginStatusInput = BossUi.buildInput(this, "登录状态备注", true); - final Spinner roleSpinner = new Spinner(this); - roleSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ROLE_LABELS)); - final Spinner providerSpinner = new Spinner(this); - providerSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, PROVIDER_LABELS)); - final LinearLayout modelFieldContainer = new LinearLayout(this); - modelFieldContainer.setOrientation(LinearLayout.VERTICAL); + final EditText loginStatusInput = BossUi.buildInput(this, "登录状态备注", true); final SwitchCompat enabledSwitch = new SwitchCompat(this); enabledSwitch.setText("启用"); - enabledSwitch.setChecked(existing == null || existing.optBoolean("enabled", true)); final SwitchCompat setActiveSwitch = new SwitchCompat(this); setActiveSwitch.setText("保存后设为当前主控"); - setActiveSwitch.setChecked(existing != null ? existing.optBoolean("isActive") : false); if (existing != null) { labelInput.setText(existing.optString("label", "")); @@ -564,53 +907,34 @@ public class AiAccountsActivity extends BossScreenActivity { accountIdentifierInput.setText(existing.optString("accountIdentifier", "")); nodeIdInput.setText(existing.optString("nodeId", "")); nodeLabelInput.setText(existing.optString("nodeLabel", "")); - modelInput.setText(existing.optString("model", "")); + modelInput.setText(existing.optString("model", defaultModelForProvider(PROVIDER_MASTER_CODEX_NODE))); loginStatusInput.setText(existing.optString("loginStatusNote", "")); - roleSpinner.setSelection(indexOf(ROLE_VALUES, existing.optString("role", "primary"))); - providerSpinner.setSelection(indexOf(PROVIDER_VALUES, existing.optString("provider", "master_codex_node"))); + enabledSwitch.setChecked(existing.optBoolean("enabled", true)); + setActiveSwitch.setChecked(existing.optBoolean("isActive")); + } else { + labelInput.setText(defaultLabelForRole(ROLE_PRIMARY)); + displayNameInput.setText("绑定电脑上的 Codex 节点"); + modelInput.setText(defaultModelForProvider(PROVIDER_MASTER_CODEX_NODE)); + loginStatusInput.setText("登录发生在绑定设备"); + enabledSwitch.setChecked(true); + setActiveSwitch.setChecked(true); } - if (apiKeyHint != null && !apiKeyHint.isEmpty()) { - apiKeyInput.setText(apiKeyHint); - } - - final Runnable refreshModelField = () -> { - modelFieldContainer.removeAllViews(); - if (ALIYUN_QWEN_PROVIDER.equals(PROVIDER_VALUES[providerSpinner.getSelectedItemPosition()])) { - modelFieldContainer.addView(aliyunModelSelection.container); - } else { - modelFieldContainer.addView(modelInput); - } - }; - providerSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - refreshModelField.run(); - } - - @Override - public void onNothingSelected(AdapterView parent) { - refreshModelField.run(); - } - }); - refreshModelField.run(); LinearLayout form = new LinearLayout(this); form.setOrientation(LinearLayout.VERTICAL); - form.addView(BossUi.buildFormCell(this, "标签", "例如 主 GPT", labelInput)); + form.addView(BossUi.buildFormCell(this, "标签", "建议使用 主要API", 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, "模型", "OpenAI / Master Node 使用通用模型名,阿里百炼会自动切换到预设选择。", modelFieldContainer)); - form.addView(BossUi.buildFormCell(this, "API Key", "OpenAI API / 阿里百炼 Qwen 模式需要", 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, "账号标识", "可填账号名或设备备注", accountIdentifierInput)); + form.addView(BossUi.buildFormCell(this, "节点 ID", "本机 Codex 节点唯一标识", nodeIdInput)); + form.addView(BossUi.buildFormCell(this, "节点名称", "例如 Mac Studio", nodeLabelInput)); + form.addView(BossUi.buildFormCell(this, "模型", "建议保持和主 Agent 当前模型一致", modelInput)); + form.addView(BossUi.buildFormCell(this, "登录状态备注", "可记录 Plus 状态、设备说明等", loginStatusInput)); form.addView(BossUi.buildFormCell(this, "启用状态", null, enabledSwitch)); form.addView(BossUi.buildFormCell(this, "保存后动作", null, setActiveSwitch)); new AlertDialog.Builder(this) - .setTitle(existing == null ? "新增 AI 账号" : "编辑 AI 账号") + .setTitle(existing == null ? "绑定主Agent节点" : "编辑主Agent节点") + .setMessage("主 Agent 优先通过绑定设备上的 Codex 节点接管。登录发生在设备端,不在手机里直接完成。") .setView(form) .setNegativeButton("取消", null) .setPositiveButton("保存", (dialog, which) -> saveAccount( @@ -620,19 +944,585 @@ public class AiAccountsActivity extends BossScreenActivity { accountIdentifierInput.getText().toString().trim(), nodeIdInput.getText().toString().trim(), nodeLabelInput.getText().toString().trim(), - ALIYUN_QWEN_PROVIDER.equals(PROVIDER_VALUES[providerSpinner.getSelectedItemPosition()]) - ? aliyunModelSelection.resolveModel() - : modelInput.getText().toString().trim(), - apiKeyInput.getText().toString().trim(), + modelInput.getText().toString().trim(), + "", + "", loginStatusInput.getText().toString().trim(), enabledSwitch.isChecked(), setActiveSwitch.isChecked(), - ROLE_VALUES[roleSpinner.getSelectedItemPosition()], - PROVIDER_VALUES[providerSpinner.getSelectedItemPosition()] + ROLE_PRIMARY, + PROVIDER_MASTER_CODEX_NODE )) .show(); } + private void openOauthAccountDialog(String role, String provider, @Nullable JSONObject existing) { + final Button quickLoginButton = BossUi.buildMiniActionButton(this, "快捷登录", true); + quickLoginButton.setOnClickListener(v -> openExternalUrl(oauthLoginUrlForProvider(provider))); + + final Spinner modelSpinner = new Spinner(this); + final String[] modelOptions = buildOauthModelOptions(existing, provider); + modelSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, modelOptions)); + modelSpinner.setSelection(findModelSelectionIndex(modelOptions, existing == null + ? defaultModelForProvider(provider) + : existing.optString("model", defaultModelForProvider(provider)))); + + boolean ready = isOauthAccountReady(existing); + modelSpinner.setEnabled(ready); + modelSpinner.setClickable(ready); + modelSpinner.setAlpha(ready ? 1f : 0.45f); + + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + form.addView(BossUi.buildFormCell( + this, + "账号快捷登录", + "点击后直接打开" + providerLabelForProvider(provider) + "登录窗口。", + quickLoginButton + )); + form.addView(BossUi.buildFormCell( + this, + "选择模型", + ready ? "当前 OAuth 状态正常,可以选择对应模型。" : "当前 OAuth 状态未就绪,登录正常后才可选择模型。", + modelSpinner + )); + + new AlertDialog.Builder(this) + .setTitle(roleLabelForRole(role) + " · " + providerLabelForProvider(provider)) + .setView(form) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> saveAccount( + existing, + existing == null ? defaultLabelForRole(role) : existing.optString("label", defaultLabelForRole(role)), + existing == null ? defaultDisplayNameForProvider(role, provider) : existing.optString("displayName", defaultDisplayNameForProvider(role, provider)), + existing == null ? "" : existing.optString("accountIdentifier", ""), + "", + "", + String.valueOf(modelSpinner.getSelectedItem()), + "", + "", + existing == null ? "待网页登录" : existing.optString("loginStatusNote", ready ? "已登录" : "待网页登录"), + existing == null || existing.optBoolean("enabled", true), + existing != null ? existing.optBoolean("isActive") : isPrimaryRole(role), + role, + provider + )) + .show(); + } + + private String[] buildOauthModelOptions(@Nullable JSONObject existing, String provider) { + java.util.ArrayList options = new java.util.ArrayList<>(); + String existingModel = existing == null ? "" : existing.optString("model", ""); + if (!existingModel.isEmpty()) { + options.add(existingModel); + } + String defaultModel = defaultModelForProvider(provider); + if (!options.contains(defaultModel)) { + options.add(defaultModel); + } + if (!options.contains("gpt-5.4-mini")) { + options.add("gpt-5.4-mini"); + } + if (!options.contains("gpt-5.4")) { + options.add("gpt-5.4"); + } + if (!options.contains("gpt-5.1")) { + options.add("gpt-5.1"); + } + if (!options.contains("gpt-4.1")) { + options.add("gpt-4.1"); + } + return options.toArray(new String[0]); + } + + private void showMasterAgentModePicker() { + final MasterAgentModePresets.ModePreset[] presets = MasterAgentModePresets.primaryChoices( + currentFastModelOverride, + currentDeepModelOverride + ); + final String[] options = MasterAgentModePresets.primaryChoiceLabels( + currentFastModelOverride, + currentDeepModelOverride + ); + int checkedIndex = MasterAgentModePresets.findPrimaryChoiceIndex( + currentMasterAgentModelOverride, + currentMasterAgentReasoningEffortOverride, + currentFastModelOverride, + currentDeepModelOverride + ); + new AlertDialog.Builder(this) + .setTitle("主Agent模式") + .setSingleChoiceItems(options, checkedIndex, (dialog, which) -> { + if (which == options.length - 1) { + dialog.dismiss(); + showAdvancedMasterAgentModelPicker(); + return; + } + MasterAgentModePresets.ModePreset preset = presets[which]; + dialog.dismiss(); + updateMasterAgentControls( + preset.modelOverride, + preset.reasoningEffortOverride, + "主Agent模式已切换为 " + preset.label + ); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showMasterAgentModeModelPicker(boolean fastMode) { + final String resolvedCurrentModel = fastMode + ? MasterAgentModePresets.resolveFastModel(currentFastModelOverride) + : MasterAgentModePresets.resolveDeepModel(currentDeepModelOverride); + final String[] options = buildModeModelOptions(resolvedCurrentModel); + int checkedIndex = findModelSelectionIndex(options, resolvedCurrentModel); + new AlertDialog.Builder(this) + .setTitle(fastMode ? "快速反应模型" : "深度思考模型") + .setSingleChoiceItems(options, checkedIndex, (dialog, which) -> { + if (which == 0) { + dialog.dismiss(); + updateMasterAgentModeModelConfig(fastMode, null); + return; + } + if (which == options.length - 1) { + dialog.dismiss(); + showCustomModeModelDialog(fastMode); + return; + } + dialog.dismiss(); + updateMasterAgentModeModelConfig(fastMode, options[which]); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showAdvancedMasterAgentModelPicker() { + final String[] options = buildMasterAgentModelOptions(); + int checkedIndex = findModelSelectionIndex( + options, + currentMasterAgentModelOverride == null ? "" : currentMasterAgentModelOverride + ); + new AlertDialog.Builder(this) + .setTitle("更多模型") + .setSingleChoiceItems(options, checkedIndex, (dialog, which) -> { + if (which == 0) { + dialog.dismiss(); + updateMasterAgentControls(null, currentMasterAgentReasoningEffortOverride, "模型已恢复默认"); + return; + } + if (which == options.length - 1) { + dialog.dismiss(); + showCustomMasterAgentModelDialog(); + return; + } + dialog.dismiss(); + updateMasterAgentControls(options[which], currentMasterAgentReasoningEffortOverride, "模型已更新为 " + options[which]); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showCustomModeModelDialog(boolean fastMode) { + final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4-mini", false); + input.setText(fastMode + ? MasterAgentModePresets.resolveFastModel(currentFastModelOverride) + : MasterAgentModePresets.resolveDeepModel(currentDeepModelOverride)); + new AlertDialog.Builder(this) + .setTitle(fastMode ? "快速反应模型" : "深度思考模型") + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> updateMasterAgentModeModelConfig( + fastMode, + normalizeControlValue(input.getText() == null ? null : input.getText().toString()) + )) + .show(); + } + + private void showCustomMasterAgentModelDialog() { + final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4-mini", false); + input.setText(currentMasterAgentModelOverride == null || currentMasterAgentModelOverride.isEmpty() + ? "gpt-5.4-mini" + : currentMasterAgentModelOverride); + new AlertDialog.Builder(this) + .setTitle("自定义模型") + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> updateMasterAgentControls( + normalizeControlValue(input.getText() == null ? null : input.getText().toString()), + currentMasterAgentReasoningEffortOverride, + "模型已更新" + )) + .show(); + } + + private String[] buildModeModelOptions(String currentModel) { + List options = new ArrayList<>(); + options.add("沿用默认"); + if (!currentModel.isEmpty()) { + options.add(currentModel); + } + String[] preferredModels = new String[]{"gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"}; + for (String model : preferredModels) { + if (!options.contains(model)) { + options.add(model); + } + } + options.add("手动输入..."); + return options.toArray(new String[0]); + } + + private String[] buildMasterAgentModelOptions() { + List options = new ArrayList<>(); + options.add("沿用默认"); + if (currentMasterAgentModelOverride != null && !currentMasterAgentModelOverride.isEmpty()) { + options.add(currentMasterAgentModelOverride); + } + String[] preferredModels = new String[]{"gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"}; + for (String model : preferredModels) { + if (!options.contains(model)) { + options.add(model); + } + } + options.add("手动输入..."); + return options.toArray(new String[0]); + } + + private void updateMasterAgentModeModelConfig(boolean fastMode, @Nullable String nextModelOverride) { + final String nextFastModelOverride = fastMode ? normalizeControlValue(nextModelOverride) : currentFastModelOverride; + final String nextDeepModelOverride = fastMode ? currentDeepModelOverride : normalizeControlValue(nextModelOverride); + final MasterAgentModePresets.ModePreset currentPreset = MasterAgentModePresets.matchPreset( + currentMasterAgentModelOverride, + currentMasterAgentReasoningEffortOverride, + currentFastModelOverride, + currentDeepModelOverride + ); + final boolean keepFastModeActive = fastMode && currentPreset != null && "fast".equals(currentPreset.key); + final boolean keepDeepModeActive = !fastMode && currentPreset != null && "deep".equals(currentPreset.key); + final String nextActiveModelOverride = keepFastModeActive + ? MasterAgentModePresets.resolveFastModel(nextFastModelOverride) + : keepDeepModeActive + ? MasterAgentModePresets.resolveDeepModel(nextDeepModelOverride) + : null; + final String nextActiveReasoningOverride = keepFastModeActive + ? "low" + : keepDeepModeActive + ? "high" + : null; + + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.updateMasterAgentModeModels( + nextFastModelOverride, + nextDeepModelOverride, + nextActiveModelOverride, + nextActiveReasoningOverride + ); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + JSONObject controls = response.json.optJSONObject("controls"); + runOnUiThread(() -> { + applyMasterAgentControls(controls); + showMessage((fastMode ? "快速反应" : "深度思考") + "模型已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + + private void updateMasterAgentControls( + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride, + String successMessage + ) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls( + "master-agent", + modelOverride, + reasoningEffortOverride + ); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + JSONObject controls = response.json.optJSONObject("controls"); + runOnUiThread(() -> { + applyMasterAgentControls(controls); + showMessage(successMessage); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + + @Nullable + private String normalizeControlValue(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if (trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed)) { + return null; + } + return trimmed; + } + + private int findModelSelectionIndex(String[] options, String selectedValue) { + for (int index = 0; index < options.length; index += 1) { + if (options[index].equals(selectedValue)) { + return index; + } + } + return 0; + } + + private boolean isOauthAccountReady(@Nullable JSONObject existing) { + if (existing == null) { + return false; + } + String status = existing.optString("status", "").trim().toLowerCase(); + String statusLabel = existing.optString("statusLabel", "").trim().toLowerCase(); + return "ready".equals(status) + || "normal".equals(status) + || "ready".equals(statusLabel) + || "normal".equals(statusLabel) + || "可用".equals(existing.optString("statusLabel", "").trim()) + || "正常".equals(existing.optString("statusLabel", "").trim()); + } + + private void openApiAccountDialog( + String role, + String provider, + @Nullable JSONObject existing, + @Nullable String apiKeyHint + ) { + final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false); + final Spinner modelSpinner = new Spinner(this); + final ArrayAdapter modelAdapter = new ArrayAdapter<>( + this, + android.R.layout.simple_spinner_item + ); + modelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + modelSpinner.setAdapter(modelAdapter); + modelSpinner.setEnabled(false); + modelSpinner.setClickable(false); + modelSpinner.setAlpha(0.45f); + final EditText apiBaseUrlInput = PROVIDER_CUSTOM_API.equals(provider) + ? BossUi.buildInput(this, "自定义 API 地址", false) + : null; + final EditText apiKeyInput = BossUi.buildInput(this, "API Key", false); + apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + final String[] validationNote = new String[] { + existing == null ? "API 连接已测试" : existing.optString("loginStatusNote", "API 连接已测试") + }; + + if (existing != null) { + accountIdentifierInput.setText(existing.optString("accountIdentifier", "")); + if (apiBaseUrlInput != null) { + apiBaseUrlInput.setText(resolveApiBaseUrl(existing)); + } + apiKeyInput.setHint(existing.optBoolean("apiKeyConfigured") ? "留空则保持当前 Key" : "API Key"); + } else { + if (apiBaseUrlInput != null) { + apiBaseUrlInput.setText(defaultApiBaseUrlForProvider(provider)); + } + } + if (apiKeyHint != null && !apiKeyHint.isEmpty()) { + apiKeyInput.setText(apiKeyHint); + } + + final Button validateButton = BossUi.buildMiniActionButton(this, "测试连接", true); + final Button saveButton = BossUi.buildMiniActionButton(this, "保存", true); + final AlertDialog[] dialogHolder = new AlertDialog[1]; + validateButton.setOnClickListener(v -> validateApiDraftConnection( + provider, + existing, + apiKeyInput, + apiBaseUrlInput, + modelSpinner, + modelAdapter, + validationNote + )); + saveButton.setOnClickListener(v -> { + if (!modelSpinner.isEnabled() || modelAdapter.getCount() <= 0 || modelSpinner.getSelectedItem() == null) { + showMessage("请先测试连接并选择模型。"); + return; + } + String resolvedApiBaseUrl = apiBaseUrlInput == null + ? defaultApiBaseUrlForProvider(provider) + : apiBaseUrlInput.getText().toString().trim(); + if (PROVIDER_CUSTOM_API.equals(provider) && resolvedApiBaseUrl.isEmpty()) { + showMessage("自定义 API 地址不能为空"); + return; + } + saveAccount( + existing, + existing == null ? defaultLabelForRole(role) : existing.optString("label", defaultLabelForRole(role)), + existing == null ? defaultDisplayNameForProvider(role, provider) : existing.optString("displayName", defaultDisplayNameForProvider(role, provider)), + accountIdentifierInput.getText().toString().trim(), + "", + "", + String.valueOf(modelSpinner.getSelectedItem()), + resolvedApiBaseUrl, + apiKeyInput.getText().toString().trim(), + validationNote[0], + existing == null || existing.optBoolean("enabled", true), + existing != null ? existing.optBoolean("isActive") : isPrimaryRole(role), + role, + provider + ); + if (dialogHolder[0] != null) { + dialogHolder[0].dismiss(); + } + }); + + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + form.addView(BossUi.buildFormCell(this, "账号标识", "可填账号名、邮箱或自定义备注", accountIdentifierInput)); + form.addView(BossUi.buildFormCell(this, "模型选择", "测试连接成功后才会出现可选模型。", modelSpinner)); + if (apiBaseUrlInput != null) { + form.addView(BossUi.buildFormCell(this, "API 地址", "自定义接入需要填写兼容接口地址。", apiBaseUrlInput)); + } + form.addView(BossUi.buildFormCell(this, "API Key", "填写后先测试连接,成功后再保存。", apiKeyInput)); + form.addView(BossUi.buildInlineActionRow(this, validateButton, saveButton)); + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(roleLabelForRole(role) + " · " + providerLabelForProvider(provider)) + .setView(form) + .setNegativeButton("取消", null) + .create(); + dialogHolder[0] = dialog; + dialog.show(); + } + + private void validateApiDraftConnection( + String provider, + @Nullable JSONObject existing, + EditText apiKeyInput, + @Nullable EditText apiBaseUrlInput, + Spinner modelSpinner, + ArrayAdapter modelAdapter, + String[] validationNote + ) { + String apiKey = apiKeyInput.getText().toString().trim(); + boolean canReuseSavedKey = apiKey.isEmpty() + && existing != null + && existing.optBoolean("apiKeyConfigured") + && !existing.optString("accountId", "").trim().isEmpty(); + if (!canReuseSavedKey && apiKey.isEmpty()) { + showMessage("API Key 不能为空"); + return; + } + String resolvedApiBaseUrl = apiBaseUrlInput == null + ? defaultApiBaseUrlForProvider(provider) + : apiBaseUrlInput.getText().toString().trim(); + if (PROVIDER_CUSTOM_API.equals(provider) && resolvedApiBaseUrl.isEmpty()) { + showMessage("自定义 API 地址不能为空"); + return; + } + + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response; + if (canReuseSavedKey) { + response = apiClient.validateAccount(existing.optString("accountId").trim()); + } else { + JSONObject payload = new JSONObject(); + payload.put("provider", provider); + payload.put("apiKey", apiKey); + if (!resolvedApiBaseUrl.isEmpty()) { + payload.put("apiBaseUrl", resolvedApiBaseUrl); + } + response = apiClient.validateDraftAccount(payload); + } + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + JSONArray availableModels = response.json.optJSONArray("availableModels"); + String selectedModel = existing == null ? "" : existing.optString("model", ""); + String message = response.message().isEmpty() ? "测试连接成功" : response.message(); + runOnUiThread(() -> { + setRefreshing(false); + validationNote[0] = message; + applyValidatedApiModels(modelSpinner, modelAdapter, availableModels, selectedModel); + showMessage(message); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("测试连接失败:" + error.getMessage()); + }); + } + }); + } + + private void applyValidatedApiModels( + Spinner spinner, + ArrayAdapter adapter, + @Nullable JSONArray availableModels, + @Nullable String selectedModel + ) { + adapter.clear(); + if (availableModels != null) { + for (int index = 0; index < availableModels.length(); index += 1) { + String model = availableModels.optString(index, "").trim(); + if (!model.isEmpty()) { + adapter.add(model); + } + } + } + adapter.notifyDataSetChanged(); + boolean hasModels = adapter.getCount() > 0; + spinner.setEnabled(hasModels); + spinner.setClickable(hasModels); + spinner.setAlpha(hasModels ? 1f : 0.45f); + if (!hasModels) { + return; + } + int selectedIndex = 0; + String targetModel = selectedModel == null ? "" : selectedModel.trim(); + for (int index = 0; index < adapter.getCount(); index += 1) { + if (adapter.getItem(index) != null && adapter.getItem(index).equals(targetModel)) { + selectedIndex = index; + break; + } + } + spinner.setSelection(selectedIndex); + } + + private String normalizeRole(String role) { + return ROLE_PRIMARY.equals(role) ? ROLE_PRIMARY : ROLE_BACKUP; + } + + private String oauthLoginUrlForProvider(String provider) { + if (PROVIDER_GOOGLE_OAUTH.equals(provider)) { + return GOOGLE_OAUTH_LOGIN_URL; + } + return CHATGPT_OAUTH_LOGIN_URL; + } + + private void openExternalUrl(String url) { + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + refreshOnResume = true; + } catch (ActivityNotFoundException error) { + showMessage("当前设备没有可用浏览器:" + error.getMessage()); + } + } + private void saveAccount( @Nullable JSONObject existing, String label, @@ -641,6 +1531,7 @@ public class AiAccountsActivity extends BossScreenActivity { String nodeId, String nodeLabel, String model, + String apiBaseUrl, String apiKey, String loginStatusNote, boolean enabled, @@ -649,14 +1540,35 @@ public class AiAccountsActivity extends BossScreenActivity { String provider ) { if (label.isEmpty() || displayName.isEmpty()) { - showMessage("标签和显示名称不能为空"); - return; - } + showMessage("标签和显示名称不能为空"); + return; + } if (model.isEmpty()) { showMessage("模型不能为空"); return; } + if (PROVIDER_MASTER_CODEX_NODE.equals(provider) && nodeId.isEmpty()) { + showMessage("节点 ID 不能为空"); + return; + } + + String resolvedApiBaseUrl = apiBaseUrl.trim(); + if (isApiProvider(provider) && resolvedApiBaseUrl.isEmpty()) { + resolvedApiBaseUrl = defaultApiBaseUrlForProvider(provider); + } + if (PROVIDER_CUSTOM_API.equals(provider) && resolvedApiBaseUrl.isEmpty()) { + showMessage("自定义 API 地址不能为空"); + return; + } + if (isApiProvider(provider) + && apiKey.trim().isEmpty() + && (existing == null || !existing.optBoolean("apiKeyConfigured"))) { + showMessage("API Key 不能为空"); + return; + } + setRefreshing(true); + String finalResolvedApiBaseUrl = resolvedApiBaseUrl; executor.execute(() -> { try { JSONObject payload = new JSONObject(); @@ -666,17 +1578,22 @@ public class AiAccountsActivity extends BossScreenActivity { payload.put("nodeId", nodeId); payload.put("nodeLabel", nodeLabel); payload.put("model", model); - payload.put("apiKey", apiKey); + payload.put("apiBaseUrl", finalResolvedApiBaseUrl); payload.put("loginStatusNote", loginStatusNote); payload.put("enabled", enabled); payload.put("setActive", setActive); payload.put("role", role); payload.put("provider", provider); + if (!apiKey.trim().isEmpty()) { + payload.put("apiKey", apiKey.trim()); + } BossApiClient.ApiResponse response = existing == null ? apiClient.createAccount(payload) : apiClient.updateAccount(existing.optString("accountId"), payload); - if (!response.ok()) throw new IllegalStateException(response.message()); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } runOnUiThread(() -> { showMessage(existing == null ? "AI 账号已新增" : "AI 账号已更新"); reload(); @@ -690,30 +1607,17 @@ public class AiAccountsActivity extends BossScreenActivity { }); } - private int indexOf(String[] values, String target) { - for (int i = 0; i < values.length; i++) { - if (values[i].equals(target)) { - return i; - } - } - return 0; - } - - private int indexOfOrMinusOne(String[] values, String target) { - for (int i = 0; i < values.length; i++) { - if (values[i].equals(target)) { - return i; - } - } - return -1; - } - private void activateAccount(JSONObject account) { setRefreshing(true); executor.execute(() -> { try { - BossApiClient.ApiResponse response = apiClient.activateAccount(account.optString("accountId"), "原生页面手动切换"); - if (!response.ok()) throw new IllegalStateException(response.message()); + BossApiClient.ApiResponse response = apiClient.activateAccount( + account.optString("accountId"), + "原生页面手动切换" + ); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } runOnUiThread(() -> { showMessage("已切换当前主控"); reload(); @@ -740,7 +1644,9 @@ public class AiAccountsActivity extends BossScreenActivity { executor.execute(() -> { try { BossApiClient.ApiResponse response = apiClient.validateAccount(accountId.trim()); - if (!response.ok()) throw new IllegalStateException(response.message()); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } runOnUiThread(() -> { showMessage(response.message()); reload(); @@ -775,7 +1681,9 @@ public class AiAccountsActivity extends BossScreenActivity { executor.execute(() -> { try { BossApiClient.ApiResponse response = apiClient.deleteAccount(account.optString("accountId")); - if (!response.ok()) throw new IllegalStateException(response.message()); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } runOnUiThread(() -> { showMessage("AI 账号已删除"); reload(); @@ -789,58 +1697,21 @@ public class AiAccountsActivity extends BossScreenActivity { }); } - private AliyunModelSelection buildAliyunQwenModelSelection(String initialModel) { - LinearLayout container = new LinearLayout(this); - container.setOrientation(LinearLayout.VERTICAL); - - final Spinner presetSpinner = new Spinner(this); - presetSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ALIYUN_QWEN_MODEL_LABELS)); - final EditText customModelInput = BossUi.buildInput(this, "自定义模型名", false); - - int presetIndex = indexOfOrMinusOne(ALIYUN_QWEN_MODEL_VALUES, initialModel); - if (presetIndex >= 0 && presetIndex < ALIYUN_QWEN_MODEL_VALUES.length - 1) { - presetSpinner.setSelection(presetIndex); - customModelInput.setVisibility(View.GONE); - } else { - presetSpinner.setSelection(ALIYUN_QWEN_MODEL_VALUES.length - 1); - customModelInput.setText(initialModel == null ? "" : initialModel); - customModelInput.setVisibility(View.VISIBLE); - } - - presetSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - customModelInput.setVisibility(position == ALIYUN_QWEN_MODEL_VALUES.length - 1 ? View.VISIBLE : View.GONE); - } - - @Override - public void onNothingSelected(AdapterView parent) { - customModelInput.setVisibility(View.GONE); - } - }); - - container.addView(BossUi.buildFormCell(this, "预设模型", "建议选择 qwen3.5-plus 或 qwen3.5-flash。", presetSpinner)); - container.addView(BossUi.buildFormCell(this, "自定义模型", "预设不适用时填写完整模型名。", customModelInput)); - return new AliyunModelSelection(container, presetSpinner, customModelInput); + private interface AccountFilter { + boolean matches(String provider); } - private static final class AliyunModelSelection { - private final LinearLayout container; - private final Spinner presetSpinner; - private final EditText customModelInput; + private static final class ProviderOption { + private final String provider; + private final String label; + private final String displayName; + private final String summary; - AliyunModelSelection(LinearLayout container, Spinner presetSpinner, EditText customModelInput) { - this.container = container; - this.presetSpinner = presetSpinner; - this.customModelInput = customModelInput; - } - - String resolveModel() { - int selectedIndex = presetSpinner.getSelectedItemPosition(); - if (selectedIndex >= 0 && selectedIndex < ALIYUN_QWEN_MODEL_VALUES.length - 1) { - return ALIYUN_QWEN_MODEL_VALUES[selectedIndex]; - } - return customModelInput.getText().toString().trim(); + private ProviderOption(String provider, String label, String displayName, String summary) { + this.provider = provider; + this.label = label; + this.displayName = displayName; + this.summary = summary; } } } diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 7746094..fe1736c 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -147,6 +147,22 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); } + public ApiResponse updateMasterAgentModeModels( + @Nullable String fastModelOverride, + @Nullable String deepModelOverride, + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride + ) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride); + payload.put("deepModelOverride", deepModelOverride == null ? JSONObject.NULL : deepModelOverride); + if (modelOverride != null || reasoningEffortOverride != null) { + payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride); + payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride); + } + return requestWithRestore("POST", "/api/v1/projects/master-agent/agent-controls", payload); + } + public ApiResponse updateProjectTakeoverSettings( String projectId, @Nullable Boolean takeoverEnabled, @@ -490,6 +506,10 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject()); } + public ApiResponse validateDraftAccount(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/accounts/validate-draft", payload); + } + public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException { return onboardAccount("/api/v1/accounts/onboard/openai-api", payload); } diff --git a/android/app/src/main/java/com/hyzq/boss/BossApplication.java b/android/app/src/main/java/com/hyzq/boss/BossApplication.java new file mode 100644 index 0000000..418e1e3 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossApplication.java @@ -0,0 +1,13 @@ +package com.hyzq.boss; + +import android.app.Application; + +import androidx.appcompat.app.AppCompatDelegate; + +public final class BossApplication extends Application { + @Override + public void onCreate() { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + super.onCreate(); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java b/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java index a0e753e..57729cc 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java +++ b/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java @@ -27,6 +27,7 @@ public final class BossMarkdown { private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$"); private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$"); private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$"); + private static final Pattern LABEL_SECTION_PATTERN = Pattern.compile("^([^::\\n]{1,24})[::]\\s*(.+)$"); private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");; private static final LruCache RENDER_CACHE = new LruCache<>(180); @@ -86,6 +87,12 @@ public final class BossMarkdown { continue; } + Matcher labelMatcher = LABEL_SECTION_PATTERN.matcher(trimmed); + if (labelMatcher.matches()) { + appendLabelSection(builder, labelMatcher.group(1), labelMatcher.group(2), palette); + continue; + } + if (trimmed.startsWith(">")) { appendQuote(builder, trimmed.substring(1).trim(), palette); continue; @@ -153,6 +160,22 @@ public final class BossMarkdown { builder.append('\n'); } + private static void appendLabelSection( + SpannableStringBuilder builder, + String label, + String content, + Palette palette + ) { + ensureBlockSeparation(builder, true); + int labelStart = builder.length(); + builder.append(label.trim()); + builder.setSpan(new StyleSpan(Typeface.BOLD), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new RelativeSizeSpan(1.03f), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.append('\n'); + appendInlineStyled(builder, content.trim(), palette); + builder.append('\n'); + } + private static void appendCodeBlock(SpannableStringBuilder builder, String text, Palette palette) { if (TextUtils.isEmpty(text)) { return; diff --git a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java index 121b685..e3a20ef 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; @@ -90,6 +91,11 @@ final class BossRealtimeClient { if (!running) { return; } + if (shouldReconnectImmediately(error)) { + Log.i(TAG, "Realtime stream timed out while idle; reconnecting immediately"); + backoffMs = INITIAL_BACKOFF_MS; + continue; + } if (shouldAttemptSessionRestore(error)) { try { BossApiClient.ApiResponse restored = apiClient.restoreSession(); @@ -174,6 +180,10 @@ final class BossRealtimeClient { && apiClient.hasRestoreToken(); } + static boolean shouldReconnectImmediately(@Nullable Exception error) { + return error instanceof SocketTimeoutException; + } + private void dispatchEventBlock(String rawBlock) { BossRealtimeEvent event = parseEventBlock(rawBlock); if (event == null || event.eventName.isEmpty()) { diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index b14b8f5..bc1b4ce 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -15,6 +15,8 @@ import android.text.TextUtils; import android.util.TypedValue; import android.view.Gravity; import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; @@ -475,6 +477,10 @@ public final class BossUi { cell.addView(helperView); } + ViewParent currentParent = field.getParent(); + if (currentParent instanceof ViewGroup) { + ((ViewGroup) currentParent).removeView(field); + } field.setPadding(field.getPaddingLeft(), field.getPaddingTop(), field.getPaddingRight(), field.getPaddingBottom()); cell.addView(field); return cell; diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index b494b95..80e265f 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -1,5 +1,6 @@ package com.hyzq.boss; +import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.os.Handler; @@ -11,6 +12,7 @@ import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; @@ -141,7 +143,17 @@ public class MainActivity extends AppCompatActivity { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); apiClient = new BossApiClient(this); - realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); + realtimeClient = new BossRealtimeClient(apiClient, new BossRealtimeClient.Listener() { + @Override + public void onRealtimeEvent(BossRealtimeEvent event) { + handleRealtimeEvent(event); + } + + @Override + public void onRealtimeConnectionChanged(boolean connected) { + runOnUiThread(() -> handleRealtimeConnectionChanged(connected)); + } + }); bindViews(); bindActions(); applyInitialTab(getIntent()); @@ -619,11 +631,8 @@ public class MainActivity extends AppCompatActivity { } private void scheduleRealtimeRefresh() { - if (realtimeRefreshScheduled) { - return; - } - realtimeRefreshScheduled = true; - uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS); + realtimeRefreshScheduled = false; + refreshCurrentTab(); } private void cancelRealtimeRefreshSchedule() { @@ -653,7 +662,9 @@ public class MainActivity extends AppCompatActivity { private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) { if ("conversation.context_indicator.updated".equals(event.eventName)) { - return false; + return hasProjectId(event) + || hasDeviceId(event) + || event.payload.optJSONArray("conversations") != null; } if ("conversation.updated".equals(event.eventName)) { return hasProjectId(event) || hasDeviceId(event); @@ -1166,6 +1177,16 @@ public class MainActivity extends AppCompatActivity { showMessage("缺少 folderKey"); return; } + if (conversationSearchMode) { + String matchedProjectId = item.optString("searchMatchProjectId", "").trim(); + String matchedProjectLabel = item.optString("searchMatchLabel", "").trim(); + if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) { + exitConversationSearchMode(true); + openProject(matchedProjectId, matchedProjectLabel); + return; + } + exitConversationSearchMode(true); + } openConversationFolder( folderKey, resolveConversationFolderName(item, row), @@ -1180,6 +1201,9 @@ public class MainActivity extends AppCompatActivity { return; } String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle; + if (conversationSearchMode) { + exitConversationSearchMode(true); + } openProject(projectId, projectName); }) )); @@ -1292,10 +1316,7 @@ public class MainActivity extends AppCompatActivity { hideConversationQuickActions(false); conversationSearchMode = true; syncTopActionVisualState(screenRefresh.isRefreshing()); - topSearchInput.post(() -> { - topSearchInput.requestFocus(); - topSearchInput.setSelection(topSearchInput.getText().length()); - }); + showConversationSearchKeyboard(); } private void exitConversationSearchMode(boolean clearQuery) { @@ -1308,12 +1329,40 @@ public class MainActivity extends AppCompatActivity { conversationSearchQuery = ""; topSearchInput.setText(""); } + hideConversationSearchKeyboard(); syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing()); if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) { renderConversationsRoot(); } } + private void showConversationSearchKeyboard() { + if (topSearchInput == null) { + return; + } + topSearchInput.post(() -> { + topSearchInput.requestFocus(); + topSearchInput.setSelection(topSearchInput.getText().length()); + InputMethodManager inputMethodManager = + (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) { + inputMethodManager.showSoftInput(topSearchInput, InputMethodManager.SHOW_IMPLICIT); + } + }); + } + + private void hideConversationSearchKeyboard() { + if (topSearchInput == null) { + return; + } + InputMethodManager inputMethodManager = + (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) { + inputMethodManager.hideSoftInputFromWindow(topSearchInput.getWindowToken(), 0); + } + topSearchInput.clearFocus(); + } + private void toggleConversationSelection(String projectId) { if (selectedConversationProjectIds.contains(projectId)) { selectedConversationProjectIds.remove(projectId); @@ -1807,6 +1856,17 @@ public class MainActivity extends AppCompatActivity { } } + void handleRealtimeConnectionChanged(boolean connected) { + if (!connected + && shouldMaintainConversationAutoRefresh() + && !rootTabRefreshInFlight + && screenRefresh != null + && !screenRefresh.isRefreshing()) { + refreshCurrentTab(); + } + updateConversationAutoRefresh(); + } + private void openMeEntry(String key) { Intent intent; switch (key) { diff --git a/android/app/src/main/java/com/hyzq/boss/MasterAgentModePresets.java b/android/app/src/main/java/com/hyzq/boss/MasterAgentModePresets.java new file mode 100644 index 0000000..4c5dce9 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/MasterAgentModePresets.java @@ -0,0 +1,121 @@ +package com.hyzq.boss; + +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +final class MasterAgentModePresets { + static final class ModePreset { + final String key; + final String label; + @Nullable final String modelOverride; + @Nullable final String reasoningEffortOverride; + + ModePreset( + String key, + String label, + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride + ) { + this.key = key; + this.label = label; + this.modelOverride = modelOverride; + this.reasoningEffortOverride = reasoningEffortOverride; + } + } + + static final ModePreset DEFAULT = new ModePreset("default", "沿用默认", null, null); + private static final String DEFAULT_FAST_MODEL = "gpt-5.4-mini"; + private static final String DEFAULT_DEEP_MODEL = "gpt-5.4"; + + private MasterAgentModePresets() {} + + static ModePreset[] primaryChoices(@Nullable String fastModelOverride, @Nullable String deepModelOverride) { + return new ModePreset[]{ + DEFAULT, + new ModePreset("fast", "快速反应", resolveFastModel(fastModelOverride), "low"), + new ModePreset("deep", "深度思考", resolveDeepModel(deepModelOverride), "high") + }; + } + + static String[] primaryChoiceLabels(@Nullable String fastModelOverride, @Nullable String deepModelOverride) { + return new String[]{ + "沿用默认", + "快速反应(" + resolveFastModel(fastModelOverride) + ")", + "深度思考(" + resolveDeepModel(deepModelOverride) + ")", + "更多模型..." + }; + } + + static int findPrimaryChoiceIndex( + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride, + @Nullable String fastModelOverride, + @Nullable String deepModelOverride + ) { + ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride); + if (preset == null) { + return primaryChoiceLabels(fastModelOverride, deepModelOverride).length - 1; + } + ModePreset[] choices = primaryChoices(fastModelOverride, deepModelOverride); + for (int index = 0; index < choices.length; index += 1) { + if (choices[index].key.equals(preset.key)) { + return index; + } + } + return 0; + } + + @Nullable + static ModePreset matchPreset( + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride, + @Nullable String fastModelOverride, + @Nullable String deepModelOverride + ) { + String model = normalize(modelOverride); + String reasoning = normalize(reasoningEffortOverride); + if (TextUtils.isEmpty(model) && TextUtils.isEmpty(reasoning)) { + return DEFAULT; + } + for (ModePreset preset : primaryChoices(fastModelOverride, deepModelOverride)) { + if (TextUtils.equals(normalize(preset.modelOverride), model) + && TextUtils.equals(normalize(preset.reasoningEffortOverride), reasoning)) { + return preset; + } + } + return null; + } + + static String describeCurrentMode( + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride, + @Nullable String fastModelOverride, + @Nullable String deepModelOverride + ) { + ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride); + return preset == null ? "自定义" : preset.label; + } + + static String resolveFastModel(@Nullable String fastModelOverride) { + String resolved = normalize(fastModelOverride); + return TextUtils.isEmpty(resolved) ? DEFAULT_FAST_MODEL : resolved; + } + + static String resolveDeepModel(@Nullable String deepModelOverride) { + String resolved = normalize(deepModelOverride); + return TextUtils.isEmpty(resolved) ? DEFAULT_DEEP_MODEL : resolved; + } + + @Nullable + private static String normalize(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if (trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed)) { + return null; + } + return trimmed; + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index a2a5eac..ae4eb9b 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -39,6 +39,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; public class ProjectDetailActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_ID = "project_id"; @@ -55,6 +56,8 @@ public class ProjectDetailActivity extends BossScreenActivity { private String projectFolderName; private @Nullable String currentAgentModelOverride; private @Nullable String currentReasoningEffortOverride; + private @Nullable String currentFastModelOverride; + private @Nullable String currentDeepModelOverride; private LinearLayout quickActionsLayout; private LinearLayout composerRow; private LinearLayout multiSelectActionsLayout; @@ -65,6 +68,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private ScrollView chatScrollView; private View pendingOutgoingBubble; private boolean composerSending; + private @Nullable String pendingReplyPresenter; private boolean renderNearBottom; private boolean renderForcedScrollToBottom; private boolean conversationInfoReady; @@ -102,6 +106,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private boolean reloadInFlight; private boolean pendingReload; private boolean pendingReloadForcedScrollToBottom; + private volatile boolean activityDestroyed; private final Runnable conversationAutoRefreshRunnable = new Runnable() { @Override public void run() { @@ -124,6 +129,12 @@ public class ProjectDetailActivity extends BossScreenActivity { triggerRealtimeReload(requireFullSnapshot); } }; + private final Runnable composerViewportSyncRunnable = new Runnable() { + @Override + public void run() { + syncChatViewportForComposer(); + } + }; static final class ChromeBindings { final boolean multiSelecting; @@ -263,10 +274,7 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override public void onRealtimeConnectionChanged(boolean connected) { - runOnUiThread(() -> { - lastKnownRealtimeConnected = connected; - syncRealtimeStatusIndicator(); - }); + runOnUiThread(() -> handleRealtimeConnectionChanged(connected)); } }); @@ -297,6 +305,7 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override public void afterTextChanged(Editable s) {} }); + bindComposerViewportSync(); updateComposerSendButtonState(); updateSelectionUi(); if (shouldLoadOnCreate()) { @@ -306,9 +315,11 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override protected void onDestroy() { + activityDestroyed = true; cancelConversationAutoRefresh(); cancelRealtimeReloadSchedule(); stopRealtimeUpdates(); + uiHandler.removeCallbacksAndMessages(null); replyWaitExecutor.shutdownNow(); super.onDestroy(); } @@ -360,7 +371,7 @@ public class ProjectDetailActivity extends BossScreenActivity { if (tryApplyRealtimeMessagesPatch(event)) { return; } - runOnUiThread(() -> scheduleRealtimeReload(!"project.messages.updated".equals(event.eventName))); + runOnUiThread(() -> scheduleRealtimeReload(true)); } private boolean tryApplyRealtimeMessagesPatch(BossRealtimeEvent event) { @@ -523,14 +534,9 @@ public class ProjectDetailActivity extends BossScreenActivity { } private void scheduleRealtimeReload(boolean requireFullSnapshot) { - if (requireFullSnapshot) { - realtimeReloadRequiresFullSnapshot = true; - } - if (realtimeReloadScheduled) { - return; - } - realtimeReloadScheduled = true; - uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS); + realtimeReloadRequiresFullSnapshot = false; + realtimeReloadScheduled = false; + triggerRealtimeReload(requireFullSnapshot); } private void cancelRealtimeReloadSchedule() { @@ -547,6 +553,9 @@ public class ProjectDetailActivity extends BossScreenActivity { } private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly) { + if (shouldSkipAsyncUiWork()) { + return; + } if (projectId == null || projectId.isEmpty()) { showMessage("缺少 projectId"); finish(); @@ -561,27 +570,45 @@ public class ProjectDetailActivity extends BossScreenActivity { renderForcedScrollToBottom = forcedScrollToBottom; reloadInFlight = true; setRefreshing(true); - executor.execute(() -> { - try { - ProjectSnapshot snapshot = messagesOnly - ? loadProjectMessagesSnapshotForRefresh() - : loadProjectSnapshotForRefresh(); - runOnUiThread(() -> { - renderLoadedProjectSnapshot(snapshot); - finishReloadCycle(); - }); - } catch (Exception error) { - runOnUiThread(() -> { - if (messagesOnly) { - reloadInFlight = false; - setRefreshing(false); - reload(forcedScrollToBottom); - return; - } - handleProjectReloadFailure(error); - finishReloadCycle(); - }); + try { + executor.execute(() -> { + try { + ProjectSnapshot snapshot = messagesOnly + ? loadProjectMessagesSnapshotForRefresh() + : loadProjectSnapshotForRefresh(); + runOnUiThreadIfActive(() -> { + renderLoadedProjectSnapshot(snapshot); + finishReloadCycle(); + }); + } catch (Exception error) { + runOnUiThreadIfActive(() -> { + if (messagesOnly) { + reloadInFlight = false; + setRefreshing(false); + reload(forcedScrollToBottom); + return; + } + handleProjectReloadFailure(error); + finishReloadCycle(); + }); + } + }); + } catch (RejectedExecutionException ignored) { + reloadInFlight = false; + setRefreshing(false); + } + } + + private boolean shouldSkipAsyncUiWork() { + return activityDestroyed || isFinishing() || isDestroyed(); + } + + private void runOnUiThreadIfActive(Runnable action) { + runOnUiThread(() -> { + if (shouldSkipAsyncUiWork()) { + return; } + action.run(); }); } @@ -654,6 +681,8 @@ public class ProjectDetailActivity extends BossScreenActivity { JSONObject agentControls = project == null ? null : project.optJSONObject("agentControls"); currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null)); currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null)); + currentFastModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("fastModelOverride", null)); + currentDeepModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("deepModelOverride", null)); if (dispatchPlans != null) { currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans); currentRejectedDispatchPlan = currentPendingDispatchPlan == null @@ -752,6 +781,19 @@ public class ProjectDetailActivity extends BossScreenActivity { syncRealtimeStatusIndicator(); } + void handleRealtimeConnectionChanged(boolean connected) { + lastKnownRealtimeConnected = connected; + syncRealtimeStatusIndicator(); + if (!connected + && shouldMaintainConversationAutoRefresh() + && !reloadInFlight + && refreshLayout != null + && !refreshLayout.isRefreshing()) { + reload(); + } + updateConversationAutoRefresh(); + } + private boolean shouldMaintainConversationAutoRefresh() { return conversationAutoRefreshEnabled && apiClient != null @@ -838,6 +880,47 @@ public class ProjectDetailActivity extends BossScreenActivity { sendProjectMessage("text", body); } + private void bindComposerViewportSync() { + if (composerInput != null) { + composerInput.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + scheduleComposerViewportSync(); + } + }); + composerInput.setOnClickListener(v -> scheduleComposerViewportSync()); + } + if (composerRow != null) { + composerRow.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + if ((bottom - top) == (oldBottom - oldTop) && bottom == oldBottom) { + return; + } + if (shouldKeepLatestMessageVisibleForInput()) { + scheduleComposerViewportSync(); + } + }); + } + } + + private void scheduleComposerViewportSync() { + uiHandler.removeCallbacks(composerViewportSyncRunnable); + uiHandler.post(composerViewportSyncRunnable); + uiHandler.postDelayed(composerViewportSyncRunnable, 96L); + } + + private void syncChatViewportForComposer() { + if (!shouldKeepLatestMessageVisibleForInput()) { + return; + } + scrollChatToBottom(); + } + + private boolean shouldKeepLatestMessageVisibleForInput() { + return composerInput != null + && composerInput.isFocused() + && composerRow != null + && composerRow.getVisibility() == View.VISIBLE; + } + private void showAttachmentEntrySheet() { if (isComposerBusy()) { return; @@ -958,6 +1041,7 @@ public class ProjectDetailActivity extends BossScreenActivity { } JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan"); JSONObject collaborationGate = response.json.optJSONObject("collaborationGate"); + String replyPresenter = response.json.optString("replyPresenter", "").trim(); ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response.json); runOnUiThread(() -> { @@ -970,8 +1054,10 @@ public class ProjectDetailActivity extends BossScreenActivity { } currentPendingDispatchPlan = dispatchPlan; currentRejectedDispatchPlan = null; + pendingReplyPresenter = replyPresenter.isEmpty() ? null : replyPresenter; if (dispatchPlan != null) { composerSending = false; + pendingReplyPresenter = null; updateComposerSendButtonState(); showMessage( "approval_required".equals(projectCollaborationMode) @@ -982,21 +1068,22 @@ public class ProjectDetailActivity extends BossScreenActivity { return; } if (waitSpec.shouldWait) { - if (isMasterAgentConversation()) { - startMasterAgentReplyWait(waitSpec, false, "消息已发送,主 Agent 思考中"); - } else { - startReplyWait(waitSpec, false, "消息已发送,正在等待回复…"); - } + startReplyWait(waitSpec, false, buildReplyWaitWaitingMessage()); return; } composerSending = false; + pendingReplyPresenter = null; updateComposerSendButtonState(); + if (tryApplyCompletedSendResponse(response.json)) { + return; + } showMessage("消息已发送"); reload(true); }); } catch (Exception error) { runOnUiThread(() -> { composerSending = false; + pendingReplyPresenter = null; setRefreshing(false); removePendingOutgoingBubble(); showMessage("发送失败:" + error.getMessage()); @@ -1006,6 +1093,51 @@ public class ProjectDetailActivity extends BossScreenActivity { }); } + private boolean tryApplyCompletedSendResponse(@Nullable JSONObject response) { + if (response == null || currentRenderedProjectPayload == null) { + return false; + } + JSONObject sentMessage = response.optJSONObject("message"); + JSONObject replyMessage = response.optJSONObject("replyMessage"); + if (sentMessage == null || replyMessage == null) { + return false; + } + JSONObject payload = copyJson(currentRenderedProjectPayload); + JSONObject project = payload.optJSONObject("project"); + if (project == null || !TextUtils.equals(project.optString("id", "").trim(), projectId)) { + return false; + } + JSONArray messages = project.optJSONArray("messages"); + if (messages == null) { + return false; + } + appendMessageIfMissing(messages, sentMessage); + appendMessageIfMissing(messages, replyMessage); + removePendingOutgoingBubble(); + clearMasterAgentReplyState(); + renderNearBottom = true; + renderForcedScrollToBottom = true; + renderLoadedProjectSnapshot(new ProjectSnapshot(payload, null, currentParticipantsPayload)); + return true; + } + + private void appendMessageIfMissing(JSONArray messages, JSONObject message) { + if (messages == null || message == null) { + return; + } + String messageId = message.optString("id", "").trim(); + if (messageId.isEmpty()) { + return; + } + for (int index = 0; index < messages.length(); index += 1) { + JSONObject existing = messages.optJSONObject(index); + if (existing != null && TextUtils.equals(existing.optString("id", "").trim(), messageId)) { + return; + } + } + messages.put(copyJson(message)); + } + private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) { String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli")) ? "GUI" @@ -1251,10 +1383,45 @@ public class ProjectDetailActivity extends BossScreenActivity { if (!isMasterAgentConversation()) { return; } + final MasterAgentModePresets.ModePreset[] presets = MasterAgentModePresets.primaryChoices( + currentFastModelOverride, + currentDeepModelOverride + ); + final String[] options = MasterAgentModePresets.primaryChoiceLabels( + currentFastModelOverride, + currentDeepModelOverride + ); + int checkedIndex = MasterAgentModePresets.findPrimaryChoiceIndex( + currentAgentModelOverride, + currentReasoningEffortOverride, + currentFastModelOverride, + currentDeepModelOverride + ); + new AlertDialog.Builder(this) + .setTitle("模型") + .setSingleChoiceItems(options, checkedIndex, (dialog, which) -> { + if (which == options.length - 1) { + dialog.dismiss(); + showAdvancedMasterAgentModelPicker(); + return; + } + MasterAgentModePresets.ModePreset preset = presets[which]; + dialog.dismiss(); + updateMasterAgentControls( + preset.modelOverride, + preset.reasoningEffortOverride, + "主Agent模式已切换为 " + preset.label + ); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showAdvancedMasterAgentModelPicker() { final String[] options = buildMasterAgentModelOptions(); int checkedIndex = findCheckedIndex(options, currentAgentModelOverride); new AlertDialog.Builder(this) - .setTitle("模型") + .setTitle("更多模型") .setSingleChoiceItems(options, checkedIndex, (dialog, which) -> { if (which == 0) { dialog.dismiss(); @@ -1275,7 +1442,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private void showCustomMasterAgentModelDialog() { final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false); - input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4" : currentAgentModelOverride); + input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4-mini" : currentAgentModelOverride); new AlertDialog.Builder(this) .setTitle("自定义模型") .setView(input) @@ -1351,14 +1518,11 @@ public class ProjectDetailActivity extends BossScreenActivity { if (!TextUtils.isEmpty(currentAgentModelOverride)) { options.add(currentAgentModelOverride); } - if (!options.contains("gpt-5.4")) { - options.add("gpt-5.4"); - } - if (!options.contains("gpt-5.1")) { - options.add("gpt-5.1"); - } - if (!options.contains("gpt-4.1")) { - options.add("gpt-4.1"); + String[] preferredModels = new String[]{"gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"}; + for (String model : preferredModels) { + if (!options.contains(model)) { + options.add(model); + } } options.add("自定义..."); return options.toArray(new String[0]); @@ -2134,9 +2298,10 @@ public class ProjectDetailActivity extends BossScreenActivity { masterAgentReplyWaiting = false; masterAgentReplyTimedOut = false; masterAgentReplyBaselineMessageId = null; + pendingReplyPresenter = null; } - private void scrollChatToBottom() { + void scrollChatToBottom() { if (chatScrollView == null) { return; } @@ -2555,7 +2720,45 @@ public class ProjectDetailActivity extends BossScreenActivity { } protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) { - replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans)); + if (shouldSkipAsyncUiWork() || replyWaitExecutor.isShutdown() || replyWaitExecutor.isTerminated()) { + return; + } + try { + replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans)); + } catch (RejectedExecutionException ignored) { + // Activity is already finishing; ignore late polling requests. + } + } + + private String buildReplyWaitWaitingMessage() { + if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) { + return "消息已发送,主 Agent 正在转述"; + } + return isMasterAgentConversation() + ? "消息已发送,主 Agent 思考中" + : "消息已发送,线程正在处理中"; + } + + private String buildReplyWaitTimeoutMessage() { + if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) { + return "主 Agent 转述暂未回流,可继续等待或稍后刷新查看。"; + } + return isMasterAgentConversation() + ? "主 Agent 回复超时,可重试等待最新回复。" + : "当前线程暂未回流,继续后台等待或稍后刷新查看。"; + } + + private String buildReplyWaitFailureMessage(Exception error) { + String detail = error.getMessage(); + if (detail == null || detail.trim().isEmpty()) { + detail = "未知错误"; + } + if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) { + return "等待主 Agent 转述失败:" + detail; + } + return isMasterAgentConversation() + ? "等待主 Agent 回复失败:" + detail + : "等待线程回复失败:" + detail; } private void pollUntilReply( @@ -2565,13 +2768,15 @@ public class ProjectDetailActivity extends BossScreenActivity { long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS; boolean renderedInitialSnapshot = false; try { - while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) { + while (!shouldSkipAsyncUiWork() + && !Thread.currentThread().isInterrupted() + && System.currentTimeMillis() < deadlineAt) { ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans); JSONObject project = snapshot.payload.optJSONObject("project"); boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, baselineMessageId); if (!renderedInitialSnapshot || hasReply) { - runOnUiThread(() -> { + runOnUiThreadIfActive(() -> { renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload); if (!hasReply && !isMasterAgentConversation()) { composerSending = true; @@ -2583,7 +2788,7 @@ public class ProjectDetailActivity extends BossScreenActivity { } if (hasReply) { - runOnUiThread(() -> { + runOnUiThreadIfActive(() -> { clearMasterAgentReplyState(); composerSending = false; updateComposerSendButtonState(); @@ -2596,7 +2801,11 @@ public class ProjectDetailActivity extends BossScreenActivity { Thread.sleep(REPLY_WAIT_POLL_INTERVAL_MS); } - runOnUiThread(() -> { + if (shouldSkipAsyncUiWork() || Thread.currentThread().isInterrupted()) { + return; + } + + runOnUiThreadIfActive(() -> { if (isMasterAgentConversation()) { masterAgentReplyWaiting = false; masterAgentReplyTimedOut = true; @@ -2604,11 +2813,16 @@ public class ProjectDetailActivity extends BossScreenActivity { composerSending = false; updateComposerSendButtonState(); setRefreshing(false); - showMessage("主 Agent 回复超时,可重试等待最新回复。"); + showMessage(buildReplyWaitTimeoutMessage()); reload(false); }); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); } catch (Exception error) { - runOnUiThread(() -> { + if (shouldSkipAsyncUiWork() || Thread.currentThread().isInterrupted()) { + return; + } + runOnUiThreadIfActive(() -> { if (isMasterAgentConversation()) { masterAgentReplyWaiting = false; masterAgentReplyTimedOut = true; @@ -2616,7 +2830,7 @@ public class ProjectDetailActivity extends BossScreenActivity { composerSending = false; updateComposerSendButtonState(); setRefreshing(false); - showMessage("等待回复失败:" + error.getMessage()); + showMessage(buildReplyWaitFailureMessage(error)); reload(false); }); } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java index ca5d756..8e7547a 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java @@ -168,6 +168,25 @@ public class ProjectGoalsActivity extends BossScreenActivity { "" )); + JSONObject understanding = project.optJSONObject("projectUnderstanding"); + if (understanding != null) { + String projectGoal = understanding.optString("projectGoal").trim(); + String currentProgress = understanding.optString("currentProgress").trim(); + String recommendedNextStep = understanding.optString("recommendedNextStep").trim(); + if (!projectGoal.isEmpty() || !currentProgress.isEmpty() || !recommendedNextStep.isEmpty()) { + StringBuilder summary = new StringBuilder(); + appendSummaryLine(summary, "项目目标", projectGoal); + appendSummaryLine(summary, "当前进度", currentProgress); + appendSummaryLine(summary, "建议下一步", recommendedNextStep); + appendContent(BossUi.buildCard( + this, + "同步项目摘要", + summary.toString().trim(), + understanding.optString("updatedAt", "") + )); + } + } + if (goals == null || goals.length() == 0) { appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。")); } else { @@ -187,6 +206,16 @@ public class ProjectGoalsActivity extends BossScreenActivity { setRefreshing(false); } + private void appendSummaryLine(StringBuilder builder, String label, String value) { + if (value == null || value.trim().isEmpty()) { + return; + } + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(label).append(":").append(value.trim()); + } + private LinearLayout buildGoalChecklistCard(JSONObject goal) { LinearLayout card = BossUi.buildCard(this, "", "", ""); card.removeAllViews(); diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java index 3dc538e..dcb6cc7 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java @@ -13,7 +13,7 @@ import java.util.Map; public class ProjectVersionsActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_NAME = "project_name"; - private static final String GOAL_REFRESH_NOTE = "project_goals.updated"; + private static final String VERSION_REFRESH_NOTE = "project_versions.updated"; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String projectId; @@ -24,7 +24,7 @@ public class ProjectVersionsActivity extends BossScreenActivity { protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); - configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME)); + configureScreen("版本记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME)); setHeaderAction("只读", v -> showMessage("版本记录只读")); realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); reload(); @@ -111,7 +111,7 @@ public class ProjectVersionsActivity extends BossScreenActivity { return false; } String payloadNote = event.payload.optString("note", "").trim(); - return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote); + return payloadProjectId.equals(projectId) && VERSION_REFRESH_NOTE.equals(payloadNote); } private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { diff --git a/android/app/src/main/res/layout/activity_project_chat.xml b/android/app/src/main/res/layout/activity_project_chat.xml index dbad95e..8613603 100644 --- a/android/app/src/main/res/layout/activity_project_chat.xml +++ b/android/app/src/main/res/layout/activity_project_chat.xml @@ -85,37 +85,48 @@ android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"> - - + android:orientation="vertical"> + android:paddingBottom="12dp"> + + + - - + android:orientation="vertical" + android:paddingLeft="12dp" + android:paddingTop="0dp" + android:paddingRight="12dp" + android:paddingBottom="20dp" /> + + @@ -95,6 +99,8 @@ android:layout_height="wrap_content" android:background="@color/boss_panel" android:orientation="vertical" + android:paddingLeft="12dp" + android:paddingRight="12dp" android:paddingTop="8dp" android:paddingBottom="24dp" /> diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index be8657c..3c65227 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -9,10 +9,11 @@ @color/boss_bg_app - diff --git a/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java b/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java index 4862651..69e3c04 100644 --- a/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java @@ -1,7 +1,9 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import android.content.Intent; import android.content.SharedPreferences; @@ -10,7 +12,6 @@ import android.view.View; import android.view.ViewGroup; import android.widget.EditText; import android.widget.Spinner; -import android.widget.SpinnerAdapter; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; @@ -35,6 +36,7 @@ import java.net.HttpURLConnection; import java.net.ProtocolException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -46,78 +48,15 @@ import java.util.concurrent.TimeUnit; @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public class AiAccountsActivityTest { - @Test - public void submitOpenAiOnboarding_reportsExplicitPrimaryControllerSuccessAndRefreshesSummary() throws Exception { - TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); - ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient( - new RecordingConnection( - new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"), - 200, - "{\"ok\":true,\"accountId\":\"acc-1\"}", - "{\"ok\":false,\"message\":\"ONBOARD_FAILED\"}" - ), - new RecordingConnection( - new URL("https://boss.hyzq.net/api/v1/accounts/acc-1/validate"), - 200, - "{\"ok\":true,\"message\":\"校验通过\"}", - "{\"ok\":false,\"message\":\"VALIDATION_FAILED\"}" - ) - )); - ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); - int initialReloadCount = activity.reloadCount; - - ReflectionHelpers.callInstanceMethod( - activity, - "submitOpenAiOnboarding", - ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"), - ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"), - ReflectionHelpers.ClassParameter.from(String.class, "sk-test"), - ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"), - ReflectionHelpers.ClassParameter.from(String.class, "sk-test-key") - ); - org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle(); - - assertEquals("OpenAI 平台账号已登录,并设为当前主控。", ShadowToast.getTextOfLatestToast()); - assertEquals(initialReloadCount + 1, activity.reloadCount); - } - - @Test - public void submitOpenAiOnboarding_showsClearChineseFailurePrefix() throws Exception { - TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); - ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient( - new RecordingConnection( - new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"), - 403, - "{\"ok\":false,\"message\":\"API Key 无效\"}", - "{\"ok\":false,\"message\":\"API Key 无效\"}" - ) - )); - ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); - - ReflectionHelpers.callInstanceMethod( - activity, - "submitOpenAiOnboarding", - ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"), - ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"), - ReflectionHelpers.ClassParameter.from(String.class, "sk-test"), - ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"), - ReflectionHelpers.ClassParameter.from(String.class, "bad-key") - ); - org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle(); - - assertEquals("OpenAI 平台账号登录失败:API Key 无效", ShadowToast.getTextOfLatestToast()); - assertEquals(1, activity.reloadCount); - } - @Test public void activeIdentityCardOffersMainAgentTestEntry() throws Exception { TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); JSONObject activeIdentity = new JSONObject() .put("accountId", "acc-1") - .put("label", "主 GPT") - .put("displayName", "OpenAI 平台账号") - .put("roleLabel", "主 GPT") - .put("providerLabel", "OpenAI API") + .put("label", "主Agent") + .put("displayName", "ChatGPT OAuth 主链路账号") + .put("roleLabel", "主链路") + .put("providerLabel", "ChatGPT登录") .put("statusLabel", "ready") .put("note", "当前账号可直接生成主 Agent 回复。") .put("canGenerate", true); @@ -140,62 +79,547 @@ public class AiAccountsActivityTest { } @Test - public void openAliyunQwenOnboardingDialogUsesPresetModelsWithCustomFallback() throws Exception { + public void renderAccountsShowsStructuredSectionsAndExpandedEntries() throws Exception { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + JSONObject payload = new JSONObject() + .put("activeIdentity", new JSONObject() + .put("accountId", "chatgpt-primary") + .put("label", "主Agent") + .put("displayName", "ChatGPT OAuth 主链路账号") + .put("roleLabel", "主链路") + .put("providerLabel", "ChatGPT登录") + .put("statusLabel", "ready") + .put("canGenerate", true)) + .put("accounts", new org.json.JSONArray() + .put(new JSONObject() + .put("accountId", "chatgpt-primary") + .put("label", "主Agent") + .put("displayName", "ChatGPT OAuth 主链路账号") + .put("roleLabel", "主链路") + .put("providerLabel", "ChatGPT登录") + .put("provider", "chatgpt_oauth") + .put("role", "primary") + .put("statusLabel", "ready") + .put("enabled", true) + .put("isActive", true)) + .put(new JSONObject() + .put("accountId", "hyzq-backup") + .put("label", "备用API") + .put("displayName", "环宇智擎 备用账号") + .put("roleLabel", "备用链路") + .put("providerLabel", "环宇智擎") + .put("provider", "hyzq_api") + .put("role", "backup") + .put("statusLabel", "ready") + .put("enabled", true) + .put("isActive", false) + .put("apiKeyConfigured", true) + .put("apiBaseUrl", "https://api.hyzq2046.com/v1")) + .put(new JSONObject() + .put("accountId", "master-node") + .put("label", "主Agent") + .put("displayName", "绑定电脑上的 Codex 节点") + .put("roleLabel", "主链路") + .put("providerLabel", "主Agent 节点") + .put("provider", "master_codex_node") + .put("role", "primary") + .put("statusLabel", "ready") + .put("enabled", true) + .put("isActive", false))); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderAccounts", + ReflectionHelpers.ClassParameter.from(JSONObject.class, payload) + ); + + View root = activity.findViewById(R.id.screen_content); + assertNotNull(root); + assertTrue(viewTreeContainsText(root, "主要API配置")); + assertTrue(viewTreeContainsText(root, "备用API配置")); + assertFalse(viewTreeContainsText(root, "OAuth 登录")); + assertFalse(viewTreeContainsText(root, "API 接入")); + assertFalse(viewTreeContainsText(root, "谷歌登录")); + assertFalse(viewTreeContainsText(root, "ChatGPT登录")); + assertFalse(viewTreeContainsText(root, "阿里")); + assertFalse(viewTreeContainsText(root, "Minimax")); + assertFalse(viewTreeContainsText(root, "GLM")); + assertFalse(viewTreeContainsText(root, "环宇智擎")); + assertFalse(viewTreeContainsText(root, "自定义")); + assertFalse(viewTreeContainsText(root, "绑定设备节点")); + } + + @Test + public void tappingPrimaryConfigEntryOpensPrimaryDetailPage() throws Exception { TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); - ReflectionHelpers.callInstanceMethod(activity, "openAliyunQwenOnboardingDialog"); + ReflectionHelpers.callInstanceMethod( + activity, + "renderAccounts", + ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray())) + ); + + View root = activity.findViewById(R.id.screen_content); + View entry = findClickableViewContainingText(root, "主要API配置"); + assertNotNull(entry); + entry.performClick(); + + ShadowActivity shadowActivity = Shadows.shadowOf(activity); + Intent nextIntent = shadowActivity.getNextStartedActivity(); + assertNotNull(nextIntent); + assertEquals(AiAccountsActivity.class.getName(), nextIntent.getComponent().getClassName()); + assertEquals("primary", nextIntent.getStringExtra("ai_accounts_role")); + } + + @Test + public void detailPageShowsOnlySelectedRoleConfiguration() throws Exception { + Intent intent = new Intent( + org.robolectric.RuntimeEnvironment.getApplication(), + TestAiAccountsActivity.class + ); + intent.putExtra("ai_accounts_role", "primary"); + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get(); + JSONObject payload = new JSONObject() + .put("accounts", new org.json.JSONArray() + .put(new JSONObject() + .put("accountId", "chatgpt-primary") + .put("label", "主要API") + .put("displayName", "ChatGPT OAuth 主链路账号") + .put("roleLabel", "主链路") + .put("providerLabel", "ChatGPT登录") + .put("provider", "chatgpt_oauth") + .put("role", "primary") + .put("model", "gpt-5.4-mini") + .put("statusLabel", "ready") + .put("enabled", true) + .put("isActive", true)) + .put(new JSONObject() + .put("accountId", "hyzq-primary") + .put("label", "主要API") + .put("displayName", "环宇智擎 主链路账号") + .put("roleLabel", "主链路") + .put("providerLabel", "环宇智擎") + .put("provider", "hyzq_api") + .put("role", "primary") + .put("model", "gpt-5.4") + .put("statusLabel", "ready") + .put("enabled", true) + .put("isActive", false) + .put("apiKeyConfigured", true) + .put("apiBaseUrl", "https://api.hyzq2046.com/v1"))); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderAccounts", + ReflectionHelpers.ClassParameter.from(JSONObject.class, payload) + ); + + View root = activity.findViewById(R.id.screen_content); + assertNotNull(root); + assertTrue(viewTreeContainsText(root, "当前使用方式")); + assertTrue(viewTreeContainsText(root, "主Agent模式")); + assertTrue(viewTreeContainsText(root, "快速反应模型")); + assertTrue(viewTreeContainsText(root, "深度思考模型")); + assertTrue(viewTreeContainsText(root, "ChatGPT登录")); + assertTrue(viewTreeContainsText(root, "OAuth 登录")); + assertTrue(viewTreeContainsText(root, "当前模型:gpt-5.4-mini")); + assertTrue(viewTreeContainsText(root, "当前:沿用默认")); + assertTrue(viewTreeContainsText(root, "当前:gpt-5.4-mini")); + assertTrue(viewTreeContainsText(root, "当前:gpt-5.4")); + assertTrue(viewTreeContainsText(root, "API 接入")); + assertTrue(viewTreeContainsText(root, "已配置:ChatGPT登录")); + assertTrue(viewTreeContainsText(root, "已配置:环宇智擎")); + assertFalse(viewTreeContainsText(root, "谷歌登录")); + assertFalse(viewTreeContainsText(root, "阿里")); + assertFalse(viewTreeContainsText(root, "Minimax")); + assertFalse(viewTreeContainsText(root, "GLM")); + assertFalse(viewTreeContainsText(root, "自定义")); + assertFalse(viewTreeContainsText(root, "可编辑配置")); + assertFalse(viewTreeContainsText(root, "当前已保存")); + assertFalse(viewTreeContainsText(root, "只读状态")); + assertFalse(viewTreeContainsText(root, "备用API配置")); + } + + @Test + public void currentMethodEntryOpensCurrentAccountEditor() throws Exception { + Intent intent = new Intent( + org.robolectric.RuntimeEnvironment.getApplication(), + TestAiAccountsActivity.class + ); + intent.putExtra("ai_accounts_role", "primary"); + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get(); + ReflectionHelpers.setField(activity, "currentMasterAgentModelOverride", "gpt-5.4-mini"); + ReflectionHelpers.setField(activity, "currentMasterAgentReasoningEffortOverride", "low"); + ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-5.4-mini"); + ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4"); + JSONObject payload = new JSONObject() + .put("accounts", new org.json.JSONArray() + .put(new JSONObject() + .put("accountId", "chatgpt-primary") + .put("label", "主要API") + .put("displayName", "ChatGPT OAuth 主链路账号") + .put("roleLabel", "主链路") + .put("providerLabel", "ChatGPT登录") + .put("provider", "chatgpt_oauth") + .put("role", "primary") + .put("model", "gpt-5.4-mini") + .put("statusLabel", "ready") + .put("enabled", true) + .put("isActive", true))); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderAccounts", + ReflectionHelpers.ClassParameter.from(JSONObject.class, payload) + ); + + View root = activity.findViewById(R.id.screen_content); + View entry = findClickableViewContainingText(root, "当前使用方式"); + assertNotNull(entry); + entry.performClick(); Shadows.shadowOf(Looper.getMainLooper()).idle(); AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); assertNotNull(dialog); - - View root = dialog.getWindow().getDecorView(); - Spinner modelSpinner = findSpinnerContainingItem(root, "qwen3.5-plus"); - assertNotNull(modelSpinner); - SpinnerAdapter adapter = modelSpinner.getAdapter(); - assertNotNull(adapter); - assertEquals(3, adapter.getCount()); - assertEquals("qwen3.5-plus", adapter.getItem(0).toString()); - assertEquals("qwen3.5-flash", adapter.getItem(1).toString()); - assertEquals("自定义模型", adapter.getItem(2).toString()); - assertEquals("qwen3.5-plus", modelSpinner.getSelectedItem().toString()); - - EditText customModelInput = findEditTextWithHint(root, "自定义模型"); - assertNotNull(customModelInput); + View dialogRoot = dialog.getWindow().getDecorView(); + assertTrue(viewTreeContainsText(dialogRoot, "账号快捷登录")); + assertTrue(viewTreeContainsText(dialogRoot, "选择模型")); } @Test - public void openAccountEditorShowsCustomFallbackForNonPresetAliyunModel() throws Exception { - TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); - JSONObject existing = new JSONObject() - .put("accountId", "acc-1") - .put("label", "备用 GPT") - .put("displayName", "阿里百炼备用账号") - .put("provider", "aliyun_qwen_api") - .put("model", "qwen-custom-x"); + public void fastModeEntryOpensDedicatedModelPicker() throws Exception { + Intent intent = new Intent( + org.robolectric.RuntimeEnvironment.getApplication(), + TestAiAccountsActivity.class + ); + intent.putExtra("ai_accounts_role", "primary"); + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get(); + ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-4.1"); + ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4"); ReflectionHelpers.callInstanceMethod( activity, - "openAccountEditor", - ReflectionHelpers.ClassParameter.from(JSONObject.class, existing), + "renderAccounts", + ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject() + .put("accounts", new org.json.JSONArray() + .put(new JSONObject() + .put("accountId", "chatgpt-primary") + .put("label", "主要API") + .put("displayName", "ChatGPT OAuth 主链路账号") + .put("roleLabel", "主链路") + .put("providerLabel", "ChatGPT登录") + .put("provider", "chatgpt_oauth") + .put("role", "primary") + .put("model", "gpt-5.4-mini") + .put("statusLabel", "ready") + .put("enabled", true) + .put("isActive", true)))) + ); + + View root = activity.findViewById(R.id.screen_content); + View entry = findClickableViewContainingText(root, "快速反应模型"); + assertNotNull(entry); + entry.performClick(); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); + assertNotNull(dialog); + View dialogRoot = dialog.getWindow().getDecorView(); + assertTrue(viewTreeContainsText(dialogRoot, "快速反应模型")); + assertTrue(viewTreeContainsText(dialogRoot, "gpt-4.1")); + } + + @Test + public void tappingOauthEntryShowsOauthProviderChooser() throws Exception { + Intent intent = new Intent( + org.robolectric.RuntimeEnvironment.getApplication(), + TestAiAccountsActivity.class + ); + intent.putExtra("ai_accounts_role", "primary"); + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderAccounts", + ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray())) + ); + + View root = activity.findViewById(R.id.screen_content); + View entry = findClickableViewContainingText(root, "OAuth 登录"); + assertNotNull(entry); + entry.performClick(); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); + assertNotNull(dialog); + View dialogRoot = dialog.getWindow().getDecorView(); + assertTrue(viewTreeContainsText(dialogRoot, "谷歌登录")); + assertTrue(viewTreeContainsText(dialogRoot, "ChatGPT登录")); + } + + @Test + public void tappingApiEntryShowsApiProviderChooser() throws Exception { + Intent intent = new Intent( + org.robolectric.RuntimeEnvironment.getApplication(), + TestAiAccountsActivity.class + ); + intent.putExtra("ai_accounts_role", "primary"); + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderAccounts", + ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray())) + ); + + View root = activity.findViewById(R.id.screen_content); + View entry = findClickableViewContainingText(root, "API 接入"); + assertNotNull(entry); + entry.performClick(); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); + assertNotNull(dialog); + View dialogRoot = dialog.getWindow().getDecorView(); + assertTrue(viewTreeContainsText(dialogRoot, "阿里")); + assertTrue(viewTreeContainsText(dialogRoot, "Minimax")); + assertTrue(viewTreeContainsText(dialogRoot, "GLM")); + assertTrue(viewTreeContainsText(dialogRoot, "环宇智擎")); + assertTrue(viewTreeContainsText(dialogRoot, "自定义")); + } + + @Test + public void defaultApiBaseUrlForProviderSupportsExpandedApiProviders() { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + + String openai = ReflectionHelpers.callInstanceMethod( + activity, + "defaultApiBaseUrlForProvider", + ReflectionHelpers.ClassParameter.from(String.class, "openai_api") + ); + String aliyun = ReflectionHelpers.callInstanceMethod( + activity, + "defaultApiBaseUrlForProvider", + ReflectionHelpers.ClassParameter.from(String.class, "aliyun_qwen_api") + ); + String minimax = ReflectionHelpers.callInstanceMethod( + activity, + "defaultApiBaseUrlForProvider", + ReflectionHelpers.ClassParameter.from(String.class, "minimax_api") + ); + String glm = ReflectionHelpers.callInstanceMethod( + activity, + "defaultApiBaseUrlForProvider", + ReflectionHelpers.ClassParameter.from(String.class, "glm_api") + ); + String hyzq = ReflectionHelpers.callInstanceMethod( + activity, + "defaultApiBaseUrlForProvider", + ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api") + ); + String custom = ReflectionHelpers.callInstanceMethod( + activity, + "defaultApiBaseUrlForProvider", + ReflectionHelpers.ClassParameter.from(String.class, "custom_api") + ); + + assertEquals("https://api.openai.com/v1", openai); + assertEquals("https://dashscope.aliyuncs.com/compatible-mode/v1", aliyun); + assertEquals("https://api.minimaxi.com/v1", minimax); + assertEquals("https://open.bigmodel.cn/api/paas/v4", glm); + assertEquals("https://api.hyzq2046.com/v1", hyzq); + assertEquals("", custom); + } + + @Test + public void openOauthAccountDialogShowsLoginAction() throws Exception { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "openOauthAccountDialog", + ReflectionHelpers.ClassParameter.from(String.class, "primary"), + ReflectionHelpers.ClassParameter.from(String.class, "google_oauth"), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null) + ); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); + assertNotNull(dialog); + View root = dialog.getWindow().getDecorView(); + assertTrue(viewTreeContainsText(root, "账号快捷登录")); + assertTrue(viewTreeContainsText(root, "谷歌登录")); + Spinner modelSpinner = findSpinner(root); + assertNotNull(modelSpinner); + assertFalse(modelSpinner.isEnabled()); + assertFalse(modelSpinner.isClickable()); + } + + @Test + public void openOauthAccountDialogEnablesModelSelectionWhenAccountIsReady() throws Exception { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + JSONObject existing = new JSONObject() + .put("label", "主要API") + .put("displayName", "ChatGPT OAuth 主链路账号") + .put("accountIdentifier", "kris@example.com") + .put("model", "gpt-5.4") + .put("loginStatusNote", "已登录") + .put("enabled", true) + .put("isActive", true) + .put("status", "ready") + .put("statusLabel", "ready"); + + ReflectionHelpers.callInstanceMethod( + activity, + "openOauthAccountDialog", + ReflectionHelpers.ClassParameter.from(String.class, "primary"), + ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth"), + ReflectionHelpers.ClassParameter.from(JSONObject.class, existing) + ); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); + assertNotNull(dialog); + View root = dialog.getWindow().getDecorView(); + Spinner modelSpinner = findSpinner(root); + assertNotNull(modelSpinner); + assertTrue(modelSpinner.isEnabled()); + assertTrue(modelSpinner.isClickable()); + } + + @Test + public void openApiAccountDialogLocksModelSelectionBeforeValidation() throws Exception { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "openApiAccountDialog", + ReflectionHelpers.ClassParameter.from(String.class, "backup"), + ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api"), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), ReflectionHelpers.ClassParameter.from(String.class, null) ); Shadows.shadowOf(Looper.getMainLooper()).idle(); AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); assertNotNull(dialog); - View root = dialog.getWindow().getDecorView(); - Spinner modelSpinner = findSpinnerContainingItem(root, "自定义模型"); + assertNotNull(findEditTextWithHint(root, "账号标识 / 备注")); + assertNotNull(findEditTextWithHint(root, "API Key")); + Spinner modelSpinner = findSpinner(root); assertNotNull(modelSpinner); - SpinnerAdapter adapter = modelSpinner.getAdapter(); - assertNotNull(adapter); - assertEquals(3, adapter.getCount()); - assertEquals("自定义模型", modelSpinner.getSelectedItem().toString()); + assertFalse(modelSpinner.isEnabled()); + assertEquals(0, ((android.widget.ArrayAdapter) modelSpinner.getAdapter()).getCount()); + } - EditText customModelInput = findEditTextWithHint(root, "自定义模型"); - assertNotNull(customModelInput); - assertEquals("qwen-custom-x", customModelInput.getText().toString()); + @Test + public void applyDraftValidatedModelsEnablesModelSelection() throws Exception { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + Spinner spinner = new Spinner(activity); + android.widget.ArrayAdapter adapter = new android.widget.ArrayAdapter<>( + activity, + android.R.layout.simple_spinner_dropdown_item, + new ArrayList<>() + ); + spinner.setAdapter(adapter); + spinner.setEnabled(false); + org.json.JSONArray models = new org.json.JSONArray().put("gpt-5.4-mini").put("gpt-5.4"); + + ReflectionHelpers.callInstanceMethod( + activity, + "applyValidatedApiModels", + ReflectionHelpers.ClassParameter.from(Spinner.class, spinner), + ReflectionHelpers.ClassParameter.from(android.widget.ArrayAdapter.class, adapter), + ReflectionHelpers.ClassParameter.from(org.json.JSONArray.class, models), + ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4") + ); + + assertTrue(spinner.isEnabled()); + assertEquals(2, adapter.getCount()); + assertEquals("gpt-5.4", spinner.getSelectedItem()); + } + + @Test + public void saveExpandedApiProviderUsesGenericCreateFlowAndAutoFillsBaseUrl() throws Exception { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + RecordingConnection createConnection = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/accounts"), + 200, + "{\"ok\":true,\"accountId\":\"acc-1\"}", + "{\"ok\":false,\"message\":\"SAVE_FAILED\"}" + ); + ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection)); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + int initialReloadCount = activity.reloadCount; + + ReflectionHelpers.callInstanceMethod( + activity, + "saveAccount", + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), + ReflectionHelpers.ClassParameter.from(String.class, "备用API"), + ReflectionHelpers.ClassParameter.from(String.class, "环宇智擎备用账号"), + ReflectionHelpers.ClassParameter.from(String.class, "fallback@example.com"), + ReflectionHelpers.ClassParameter.from(String.class, ""), + ReflectionHelpers.ClassParameter.from(String.class, ""), + ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"), + ReflectionHelpers.ClassParameter.from(String.class, ""), + ReflectionHelpers.ClassParameter.from(String.class, "hyzq-secret"), + ReflectionHelpers.ClassParameter.from(String.class, "待校验"), + ReflectionHelpers.ClassParameter.from(boolean.class, true), + ReflectionHelpers.ClassParameter.from(boolean.class, false), + ReflectionHelpers.ClassParameter.from(String.class, "backup"), + ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api") + ); + org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle(); + + assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast()); + assertEquals(initialReloadCount + 1, activity.reloadCount); + + JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody()); + assertEquals("hyzq_api", requestJson.getString("provider")); + assertEquals("backup", requestJson.getString("role")); + assertEquals("https://api.hyzq2046.com/v1", requestJson.getString("apiBaseUrl")); + assertEquals("hyzq-secret", requestJson.getString("apiKey")); + } + + @Test + public void saveOauthAccountUsesGenericCreateFlow() throws Exception { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + RecordingConnection createConnection = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/accounts"), + 200, + "{\"ok\":true,\"accountId\":\"acc-2\"}", + "{\"ok\":false,\"message\":\"SAVE_FAILED\"}" + ); + ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection)); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + + ReflectionHelpers.callInstanceMethod( + activity, + "saveAccount", + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), + ReflectionHelpers.ClassParameter.from(String.class, "主Agent"), + ReflectionHelpers.ClassParameter.from(String.class, "ChatGPT OAuth 主链路账号"), + ReflectionHelpers.ClassParameter.from(String.class, "kris@example.com"), + ReflectionHelpers.ClassParameter.from(String.class, ""), + ReflectionHelpers.ClassParameter.from(String.class, ""), + ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"), + ReflectionHelpers.ClassParameter.from(String.class, ""), + ReflectionHelpers.ClassParameter.from(String.class, ""), + ReflectionHelpers.ClassParameter.from(String.class, "待网页登录"), + ReflectionHelpers.ClassParameter.from(boolean.class, true), + ReflectionHelpers.ClassParameter.from(boolean.class, true), + ReflectionHelpers.ClassParameter.from(String.class, "primary"), + ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth") + ); + org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle(); + + assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast()); + JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody()); + assertEquals("chatgpt_oauth", requestJson.getString("provider")); + assertEquals("primary", requestJson.getString("role")); + assertEquals("待网页登录", requestJson.getString("loginStatusNote")); + assertEquals("", requestJson.getString("apiBaseUrl")); } private static final class TestAiAccountsActivity extends AiAccountsActivity { @@ -279,8 +703,6 @@ public class AiAccountsActivityTest { private final int responseCodeValue; private final String responseBody; private final String errorBody; - private String requestMethodValue = "GET"; - private String contentTypeValue = ""; RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) { super(url); @@ -301,16 +723,11 @@ public class AiAccountsActivityTest { public void connect() {} @Override - public void setRequestMethod(String method) throws ProtocolException { - requestMethodValue = method; - } + public void setRequestMethod(String method) throws ProtocolException {} @Override public void setRequestProperty(String key, String value) { requestHeaders.put(key, value); - if ("Content-Type".equalsIgnoreCase(key)) { - contentTypeValue = value; - } } @Override @@ -337,6 +754,10 @@ public class AiAccountsActivityTest { public Map> getHeaderFields() { return Collections.emptyMap(); } + + String getCapturedRequestBody() { + return requestBody.toString(StandardCharsets.UTF_8); + } } private static final class InMemorySharedPreferences implements SharedPreferences { @@ -484,32 +905,6 @@ public class AiAccountsActivityTest { return false; } - private static Spinner findSpinnerContainingItem(View root, String expectedText) { - if (root instanceof Spinner) { - Spinner spinner = (Spinner) root; - SpinnerAdapter adapter = spinner.getAdapter(); - if (adapter != null) { - for (int index = 0; index < adapter.getCount(); index += 1) { - Object item = adapter.getItem(index); - if (item != null && item.toString().contains(expectedText)) { - return spinner; - } - } - } - } - if (!(root instanceof ViewGroup)) { - return null; - } - ViewGroup group = (ViewGroup) root; - for (int index = 0; index < group.getChildCount(); index += 1) { - Spinner match = findSpinnerContainingItem(group.getChildAt(index), expectedText); - if (match != null) { - return match; - } - } - return null; - } - private static EditText findEditTextWithHint(View root, String expectedText) { if (root instanceof EditText) { CharSequence hint = ((EditText) root).getHint(); @@ -529,4 +924,41 @@ public class AiAccountsActivityTest { } return null; } + + private static EditText findEditTextWithText(View root, String expectedText) { + if (root instanceof EditText) { + CharSequence text = ((EditText) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return (EditText) root; + } + } + if (!(root instanceof ViewGroup)) { + return null; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + EditText match = findEditTextWithText(group.getChildAt(index), expectedText); + if (match != null) { + return match; + } + } + return null; + } + + private static Spinner findSpinner(View root) { + if (root instanceof Spinner) { + return (Spinner) root; + } + if (!(root instanceof ViewGroup)) { + return null; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + Spinner match = findSpinner(group.getChildAt(index)); + if (match != null) { + return match; + } + } + return null; + } } diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java index c0b587e..bb6ae42 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -170,6 +170,27 @@ public class BossApiClientDispatchPlansTest { ); } + @Test + public void updateMasterAgentModeModelsWritesFastAndDeepModelMappings() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.updateMasterAgentModeModels( + "gpt-4.1", + "gpt-5.1", + "gpt-4.1", + "low" + ); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals( + "{\"fastModelOverride\":\"gpt-4.1\",\"deepModelOverride\":\"gpt-5.1\",\"modelOverride\":\"gpt-4.1\",\"reasoningEffortOverride\":\"low\"}", + connection.requestBody() + ); + } + @Test public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception { RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile")); diff --git a/android/app/src/test/java/com/hyzq/boss/BossMarkdownTest.java b/android/app/src/test/java/com/hyzq/boss/BossMarkdownTest.java index 26a5a51..0c715de 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossMarkdownTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossMarkdownTest.java @@ -60,4 +60,32 @@ public class BossMarkdownTest { assertEquals("(空消息)", rendered.toString()); } + + @Test + public void render_normalizesColonSectionsIntoReadableBlocks() { + Context context = RuntimeEnvironment.getApplication(); + + CharSequence rendered = BossMarkdown.render( + context, + "项目目标:完成 Boss 真机回归\n" + + "当前进度:已完成 UI 调整\n" + + "下一步:推送到 Gitea", + false + ); + + assertTrue(rendered instanceof Spanned); + Spanned spanned = (Spanned) rendered; + String text = spanned.toString(); + + assertTrue(text.contains("项目目标")); + assertTrue(text.contains("完成 Boss 真机回归")); + assertTrue(text.indexOf("项目目标") < text.indexOf("完成 Boss 真机回归")); + assertTrue(text.contains("当前进度")); + assertTrue(text.contains("已完成 UI 调整")); + assertTrue(text.indexOf("当前进度") < text.indexOf("已完成 UI 调整")); + assertTrue(text.contains("下一步")); + assertTrue(text.contains("推送到 Gitea")); + assertTrue(text.indexOf("下一步") < text.indexOf("推送到 Gitea")); + assertTrue(text.contains("\n")); + } } diff --git a/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java b/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java index 79e22d1..c3a559e 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java @@ -1,13 +1,18 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import java.net.SocketTimeoutException; +import java.io.IOException; + @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public class BossRealtimeClientTest { @@ -37,4 +42,10 @@ public class BossRealtimeClientTest { public void parseEventBlockReturnsNullForEmptyEventPayloads() { assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n")); } + + @Test + public void socketTimeoutReconnectsImmediately() { + assertTrue(BossRealtimeClient.shouldReconnectImmediately(new SocketTimeoutException("timeout"))); + assertFalse(BossRealtimeClient.shouldReconnectImmediately(new IOException("boom"))); + } } diff --git a/android/app/src/test/java/com/hyzq/boss/BossUiFormCellTest.java b/android/app/src/test/java/com/hyzq/boss/BossUiFormCellTest.java new file mode 100644 index 0000000..472c704 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossUiFormCellTest.java @@ -0,0 +1,28 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertSame; + +import android.content.Context; +import android.widget.EditText; +import android.widget.LinearLayout; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class BossUiFormCellTest { + @Test + public void buildFormCell_detachesFieldFromPreviousParentBeforeReusingIt() { + Context context = RuntimeEnvironment.getApplication(); + EditText field = new EditText(context); + + BossUi.buildFormCell(context, "模型", "第一次渲染", field); + LinearLayout secondCell = BossUi.buildFormCell(context, "模型", "刷新后重建", field); + + assertSame(secondCell, field.getParent()); + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java index 97c9a3f..713c026 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java @@ -5,8 +5,10 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import android.content.Context; import android.content.Intent; import android.view.View; +import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.ImageButton; import android.widget.LinearLayout; @@ -21,6 +23,8 @@ import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.Shadows; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowInputMethodManager; import org.robolectric.util.ReflectionHelpers; @RunWith(RobolectricTestRunner.class) @@ -124,7 +128,58 @@ public class MainActivityConversationSearchTest { } @Test - public void searchHitInsideArchivedProject_keepsProjectContextAndOpensFolderPage() throws Exception { + public void searchMode_showsSoftKeyboardWhenActivated() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "conversationsData", buildConversations()); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + ImageButton searchButton = activity.findViewById(R.id.search_button); + searchButton.performClick(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + EditText searchInput = activity.findViewById(R.id.top_search_input); + InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE); + ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager); + + assertTrue(searchInput.isFocused()); + assertTrue(shadowInputMethodManager.isSoftInputVisible()); + } + + @Test + public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "conversationsData", buildConversations()); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + ImageButton searchButton = activity.findViewById(R.id.search_button); + searchButton.performClick(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + EditText searchInput = activity.findViewById(R.id.top_search_input); + searchInput.setText("树莓派"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + View row = getRecyclerChild(list, 0); + row.performClick(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity(); + InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE); + ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager); + + assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName()); + assertEquals("p1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID)); + assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode")); + assertEquals("", searchInput.getText().toString()); + assertFalse(shadowInputMethodManager.isSoftInputVisible()); + assertFalse(activity.isFinishing()); + } + + @Test + public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); ReflectionHelpers.setField(activity, "conversationsData", new JSONArray() .put(new JSONObject() @@ -155,14 +210,50 @@ public class MainActivityConversationSearchTest { row.performClick(); Shadows.shadowOf(activity.getMainLooper()).idle(); + Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity(); + assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName()); + assertEquals("thread-revert-1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID)); + assertEquals("发布回滚", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME)); + assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode")); + assertEquals("", searchInput.getText().toString()); + assertFalse(activity.isFinishing()); + } + + @Test + public void archivedProjectSearchByFolderName_stillOpensFolderPage() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "conversationsData", new JSONArray() + .put(new JSONObject() + .put("projectId", "folder-boss") + .put("conversationType", "folder_archive") + .put("folderKey", "mac-studio:boss") + .put("folderLabel", "Boss") + .put("projectTitle", "Boss") + .put("threadTitle", "Boss") + .put("lastMessagePreview", "最近:发布回滚") + .put("latestReplyLabel", "11:00") + .put("searchAliases", new JSONArray().put("发布回滚")) + .put("searchTargetProjectIds", new JSONArray().put("thread-revert-1")))); + + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + ReflectionHelpers.callInstanceMethod(activity, "enterConversationSearchMode"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + EditText searchInput = activity.findViewById(R.id.top_search_input); + searchInput.setText("Boss"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + View row = getRecyclerChild(list, 0); + row.performClick(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity(); assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName()); assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY)); - assertEquals("Boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME)); - assertEquals("thread-revert-1", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID)); - assertEquals(2, nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS).length); - assertEquals("thread-revert-2", nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS)[1]); - assertEquals("发布回滚", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL)); + assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode")); + assertEquals("", searchInput.getText().toString()); } private static JSONArray buildConversations() throws Exception { diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java index d613bbe..8cb4c9c 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java @@ -260,6 +260,27 @@ public class MainActivityRealtimeTest { assertEquals(0, activity.meRefreshCount); } + @Test + public void realtimeDisconnectTriggersImmediateConversationFallbackRefresh() throws Exception { + TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); + activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE) + .edit() + .putString("session_cookie", "boss_session=test") + .apply(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeConnectionChanged", + ReflectionHelpers.ClassParameter.from(boolean.class, false) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.conversationRefreshCount); + assertEquals(0, activity.deviceRefreshCount); + assertEquals(0, activity.meRefreshCount); + } + @Test public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java index b6687a4..ccfc01f 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java @@ -1,6 +1,7 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -51,6 +52,66 @@ public class ProjectDetailActivityMasterAgentMenuTest { assertMenuItem(listView, 6, "刷新"); } + @Test + public void masterAgentModelOptionsIncludeFastAndDeepChoices() { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric + .buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent) + .setup() + .get(); + + String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions"); + + assertArrayEquals( + new String[]{"沿用默认", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."}, + options + ); + } + + @Test + public void masterAgentModelOptionsKeepCurrentCustomChoice() { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric + .buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent) + .setup() + .get(); + ReflectionHelpers.setField(activity, "currentAgentModelOverride", "gpt-4.1-mini"); + + String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions"); + + assertArrayEquals( + new String[]{"沿用默认", "gpt-4.1-mini", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."}, + options + ); + } + + @Test + public void masterAgentModelPickerShowsFastAndDeepModes() { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric + .buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentModelPicker"); + + android.app.Dialog latestDialog = ShadowDialog.getLatestDialog(); + assertTrue(latestDialog instanceof AlertDialog); + AlertDialog actionDialog = (AlertDialog) latestDialog; + ListView listView = actionDialog.getListView(); + + assertMenuItem(listView, 0, "沿用默认"); + assertMenuItem(listView, 1, "快速反应(gpt-5.4-mini)"); + assertMenuItem(listView, 2, "深度思考(gpt-5.4)"); + assertMenuItem(listView, 3, "更多模型..."); + } + @Test public void normalConversationMoreMenuShowsInfoAndRefresh() { Intent intent = new Intent() diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java index f46ec00..84f96f6 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java @@ -251,6 +251,54 @@ public class ProjectDetailActivityRealtimeTest { assertEquals(2, activity.renderCount); } + @Test + public void realtimeDisconnectTriggersImmediateConversationFallbackReload() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线"); + TestRealtimeProjectDetailActivity activity = Robolectric + .buildActivity(TestRealtimeProjectDetailActivity.class, intent) + .setup() + .resume() + .get(); + activity.getSharedPreferences("boss_native_client", android.content.Context.MODE_PRIVATE) + .edit() + .putString("session_cookie", "boss_session=test") + .apply(); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeConnectionChanged", + ReflectionHelpers.ClassParameter.from(boolean.class, false) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void reloadSnapshotAfterDestroyDoesNotCrashWhenExecutorsAreShutdown() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线"); + TestRealtimeProjectDetailActivity activity = Robolectric + .buildActivity(TestRealtimeProjectDetailActivity.class, intent) + .setup() + .resume() + .pause() + .destroy() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "reloadSnapshot", + ReflectionHelpers.ClassParameter.from(boolean.class, false), + ReflectionHelpers.ClassParameter.from(boolean.class, false) + ); + + assertEquals(0, activity.loadCallCount); + } + private static void waitFor(BooleanSupplier condition) throws Exception { long deadlineAt = System.currentTimeMillis() + 2_000L; while (System.currentTimeMillis() < deadlineAt) { diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java index 49d2e65..6a677bf 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -2,8 +2,9 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import android.content.Intent; import android.content.SharedPreferences; @@ -14,6 +15,7 @@ import android.widget.Button; import android.widget.ImageButton; import android.widget.LinearLayout; import android.widget.ListView; +import android.widget.ScrollView; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; import org.json.JSONArray; @@ -23,6 +25,7 @@ import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.robolectric.Shadows; import org.robolectric.shadows.ShadowDialog; import org.robolectric.util.ReflectionHelpers; @@ -30,6 +33,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.function.BooleanSupplier; @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) @@ -87,6 +91,91 @@ public class ProjectDetailActivityUiTest { assertEquals(View.GONE, refreshButton.getVisibility()); } + @Test + public void composerFocus_scrollsChatToBottomToKeepLatestMessageVisible() { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + View composerInput = activity.findViewById(R.id.project_chat_input); + composerInput.requestFocus(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertTrue(composerInput.isFocused()); + assertTrue(activity.scrollChatToBottomCount > 0); + } + + @Test + public void quickActionsStayOutsideScrollableMessageContainer() { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + LinearLayout quickActions = activity.findViewById(R.id.project_chat_quick_actions); + ScrollView chatScrollView = activity.findViewById(R.id.project_chat_scroll); + LinearLayout contentLayout = activity.findViewById(R.id.screen_content); + + assertNotNull(quickActions); + assertNotNull(chatScrollView); + assertNotNull(contentLayout); + assertEquals(R.id.project_chat_quick_actions_container, ((View) quickActions.getParent()).getId()); + assertEquals(View.NO_ID, ((View) chatScrollView.getParent()).getId()); + assertEquals(R.id.project_chat_scroll, ((View) contentLayout.getParent()).getId()); + } + + @Test + public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + View composerInput = activity.findViewById(R.id.project_chat_input); + View composerRow = activity.findViewById(R.id.project_chat_composer_row); + + composerRow.layout(0, 0, 1080, 120); + composerInput.requestFocus(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + int baselineScrollCount = activity.scrollChatToBottomCount; + + composerRow.layout(0, 0, 1080, 220); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertTrue(activity.scrollChatToBottomCount > baselineScrollCount); + } + + @Test + public void composerRowLayoutChangeWithoutFocusedInput_doesNotScrollChatToBottom() { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + View composerRow = activity.findViewById(R.id.project_chat_composer_row); + composerRow.layout(0, 0, 1080, 120); + Shadows.shadowOf(activity.getMainLooper()).idle(); + int baselineScrollCount = activity.scrollChatToBottomCount; + + composerRow.layout(0, 0, 1080, 220); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(baselineScrollCount, activity.scrollChatToBottomCount); + } + @Test public void manualAnalysisAttachmentShowsActionChip() throws Exception { Intent intent = new Intent() @@ -233,6 +322,77 @@ public class ProjectDetailActivityUiTest { assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26")); } + @Test + public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + JSONObject initialPayload = new JSONObject() + .put("project", new JSONObject() + .put("id", "master-agent") + .put("name", "主 Agent") + .put("messages", new JSONArray())); + ReflectionHelpers.callInstanceMethod( + activity, + "renderProject", + ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload), + ReflectionHelpers.ClassParameter.from(JSONArray.class, null), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null) + ); + + JSONObject userMessage = new JSONObject() + .put("id", "msg-user-fast") + .put("sender", "user") + .put("senderLabel", "Boss 超级管理员") + .put("body", "你现在是什么模型") + .put("kind", "text") + .put("sentAt", "2026-04-17T10:00:00.000Z"); + JSONObject replyMessage = new JSONObject() + .put("id", "msg-master-fast") + .put("sender", "master") + .put("senderLabel", "主 Agent · gpt-5.4-mini") + .put("body", "当前主 Agent 是 gpt-5.4-mini。") + .put("kind", "text") + .put("sentAt", "2026-04-17T10:00:01.000Z"); + JSONObject sendResponse = new JSONObject() + .put("ok", true) + .put("message", userMessage) + .put("replyMessage", replyMessage) + .put("masterReplyState", "completed") + .put("replyPresenter", "master") + .put("task", JSONObject.NULL) + .put("dispatchPlan", JSONObject.NULL) + .put("collaborationGate", new JSONObject() + .put("isGroup", false) + .put("collaborationMode", "development") + .put("approvalState", "not_required")); + CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse); + ReflectionHelpers.setField(activity, "apiClient", fakeApiClient); + + ReflectionHelpers.callInstanceMethod( + activity, + "sendProjectMessage", + ReflectionHelpers.ClassParameter.from(String.class, "text"), + ReflectionHelpers.ClassParameter.from(String.class, "你现在是什么模型") + ); + + waitForUiCondition( + activity, + () -> viewTreeContainsText(activity.findViewById(R.id.screen_content), "当前主 Agent 是 gpt-5.4-mini。") + || fakeApiClient.projectDetailCallCount > 0 + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "你现在是什么模型")); + assertTrue(viewTreeContainsText(content, "当前主 Agent 是 gpt-5.4-mini。")); + assertEquals(0, fakeApiClient.projectDetailCallCount); + } + @Test public void masterAgentHeaderUsesWechatMoreMenuLabel() { Intent intent = new Intent() @@ -275,6 +435,21 @@ public class ProjectDetailActivityUiTest { assertEquals("更多", String.valueOf(headerAction.getContentDescription())); } + @Test + public void normalConversationUsesThreadSpecificReplyWaitTimeoutCopy() { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + String timeoutMessage = ReflectionHelpers.callInstanceMethod(activity, "buildReplyWaitTimeoutMessage"); + + assertEquals("当前线程暂未回流,继续后台等待或稍后刷新查看。", timeoutMessage); + } + @Test public void renderProjectKeepsMasterAgentWaitingStateVisibleInMessageFlow() throws Exception { Intent intent = new Intent() @@ -703,10 +878,23 @@ public class ProjectDetailActivityUiTest { return null; } + private static void waitForUiCondition(TestProjectDetailActivity activity, BooleanSupplier condition) throws Exception { + long deadline = System.currentTimeMillis() + 2_000L; + while (System.currentTimeMillis() < deadline) { + Shadows.shadowOf(activity.getMainLooper()).idle(); + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(10L); + } + fail("condition was not met before timeout"); + } + public static class TestProjectDetailActivity extends ProjectDetailActivity { int replyWaitPollCount; String lastReplyWaitBaselineMessageId; boolean lastReplyWaitIncludeDispatchPlans; + int scrollChatToBottomCount; @Override boolean shouldLoadOnCreate() { @@ -719,6 +907,40 @@ public class ProjectDetailActivityUiTest { lastReplyWaitBaselineMessageId = baselineMessageId; lastReplyWaitIncludeDispatchPlans = includeDispatchPlans; } + + @Override + void scrollChatToBottom() { + scrollChatToBottomCount += 1; + } + } + + private static final class CompletedReplyApiClient extends BossApiClient { + private final JSONObject sendResponse; + int projectDetailCallCount; + + CompletedReplyApiClient(JSONObject sendResponse) { + super(new InMemorySharedPreferences(), "https://boss.hyzq.net"); + this.sendResponse = sendResponse; + } + + @Override + public ApiResponse sendProjectMessage(String projectId, String body, String kind) { + return new ApiResponse(200, sendResponse); + } + + @Override + public ApiResponse getProjectDetail(String projectId) throws org.json.JSONException { + projectDetailCallCount += 1; + return new ApiResponse( + 200, + new JSONObject() + .put("ok", true) + .put("project", new JSONObject() + .put("id", projectId) + .put("name", "主 Agent") + .put("messages", new JSONArray())) + ); + } } private static final class InMemorySharedPreferences implements SharedPreferences { diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityUiTest.java index cd9b2b3..3c04537 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityUiTest.java @@ -28,7 +28,7 @@ public class ProjectGoalsActivityUiTest { TestProjectGoalsActivity activity = Robolectric .buildActivity(TestProjectGoalsActivity.class, new Intent() .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1") - .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归")) + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠")) .setup() .get(); @@ -38,16 +38,48 @@ public class ProjectGoalsActivityUiTest { ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()) ); + activity.configureScreen("项目目标", "北区试产线回归需要只展示一行避免堆叠"); LinearLayout content = activity.findViewById(R.id.screen_content); + TextView subtitle = activity.findViewById(R.id.screen_subtitle); assertTrue(viewTreeContainsText(content, "主 Agent 已整理项目目标 · 已完成 1/3")); assertTrue(viewTreeContainsSubstring(content, "完成北区试产线全链路回归")); assertTrue(viewTreeContainsSubstring(content, "已完成 · 09:12 由主 Agent 复核")); assertTrue(viewTreeContainsText(content, "当前约束")); + assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12))); + assertTrue(subtitle.getMaxLines() <= 1); + assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END")); assertFalse(viewTreeContainsText(content, "标记完成")); assertFalse(viewTreeContainsText(content, "编辑目标")); assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing()); } + @Test + public void renderGoalsShowsSyncedProjectUnderstandingSummary() throws Exception { + TestProjectGoalsActivity activity = Robolectric + .buildActivity(TestProjectGoalsActivity.class, new Intent() + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠")) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderGoals", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject() + .put("projectUnderstanding", new JSONObject() + .put("projectGoal", "完成北区试产线与主 Agent 接管回归") + .put("currentProgress", "已把最新核对结果同步到项目目标页顶部") + .put("recommendedNextStep", "继续完成 Gitea 推送和真机回归") + .put("updatedAt", "2026-04-18T10:28:00.000Z"))) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "同步项目摘要")); + assertTrue(viewTreeContainsSubstring(content, "完成北区试产线与主 Agent 接管回归")); + assertTrue(viewTreeContainsSubstring(content, "已把最新核对结果同步到项目目标页顶部")); + assertTrue(viewTreeContainsSubstring(content, "继续完成 Gitea 推送和真机回归")); + } + private static JSONObject buildProject() throws Exception { JSONArray goals = new JSONArray() .put(new JSONObject() @@ -106,6 +138,10 @@ public class ProjectGoalsActivityUiTest { return false; } + private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) { + return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx; + } + public static class TestProjectGoalsActivity extends ProjectGoalsActivity { @Override protected void reload() { diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityTest.java index 1503b5b..190bb07 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityTest.java @@ -19,7 +19,7 @@ import java.lang.reflect.Method; @Config(sdk = 34) public class ProjectVersionsActivityTest { @Test - public void matchingGoalRefreshMarkerTriggersReload() throws Exception { + public void matchingVersionRefreshMarkerTriggersReload() throws Exception { Intent intent = new Intent() .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); @@ -40,7 +40,7 @@ public class ProjectVersionsActivityTest { "conversation.updated", new JSONObject() .put("projectId", "project-1") - .put("note", "project_goals.updated") + .put("note", "project_versions.updated") ) ); Shadows.shadowOf(activity.getMainLooper()).idle(); @@ -49,7 +49,7 @@ public class ProjectVersionsActivityTest { } @Test - public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception { + public void sameProjectNonVersionEventDoesNotTriggerReload() throws Exception { Intent intent = new Intent() .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityUiTest.java index 49dc639..ded9808 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityUiTest.java @@ -1,6 +1,7 @@ package com.hyzq.boss; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import android.content.Intent; @@ -28,7 +29,7 @@ public class ProjectVersionsActivityUiTest { TestProjectVersionsActivity activity = Robolectric .buildActivity(TestProjectVersionsActivity.class, new Intent() .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") - .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归")) + .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠")) .setup() .get(); @@ -38,11 +39,18 @@ public class ProjectVersionsActivityUiTest { ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()) ); + activity.configureScreen("版本记录", "北区试产线回归需要只展示一行避免堆叠"); LinearLayout content = activity.findViewById(R.id.screen_content); + TextView title = activity.findViewById(R.id.screen_title); + TextView subtitle = activity.findViewById(R.id.screen_subtitle); + assertEquals("版本记录", String.valueOf(title.getText())); assertTrue(viewTreeContainsText(content, "仅主 Agent 可发布迭代记录")); assertTrue(viewTreeContainsText(content, "v1.2.8 已发布")); assertTrue(viewTreeContainsSubstring(content, "• 优化 OTA 实时提示")); assertTrue(viewTreeContainsText(content, "主 Agent 复核记录")); + assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12))); + assertTrue(subtitle.getMaxLines() <= 1); + assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END")); assertFalse(viewTreeContainsText(content, "版本记录只读")); assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing()); } @@ -98,6 +106,10 @@ public class ProjectVersionsActivityUiTest { return false; } + private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) { + return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx; + } + public static class TestProjectVersionsActivity extends ProjectVersionsActivity { @Override protected void reload() { diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 6801207..d4e907e 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -178,6 +178,7 @@ cd /Users/kris/code/boss - 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` - 当前最新 release 构建版本:`2.5.11`(`versionCode=24`) - 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties` +- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一只使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机 - Android 真机无线调试当前可恢复使用,但系统层面没有“永久保持无线调试开启”的官方稳定开关;重启、切网、ADB server 重启或重新切换 USB 调试后,都可能自动失效 - 如果要尽量稳定,当前推荐做法是:同一局域网下先走 USB 启用,再执行 `adb tcpip 5555` 与 `adb connect :5555`;同时固定同一 SSID、避免切热点/VPN、开启“保持唤醒”,并保留 USB 作为长时间调试兜底 - `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退 diff --git a/eslint.config.mjs b/eslint.config.mjs index 760241d..9b201ce 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ const eslintConfig = defineConfig([ ".next/**", "out/**", "build/**", + "main-*.js", "android/.gradle/**", "android/**/build/**", "next-env.d.ts", diff --git a/src/app/api/v1/accounts/[accountId]/route.ts b/src/app/api/v1/accounts/[accountId]/route.ts index 271b837..1eec0c5 100644 --- a/src/app/api/v1/accounts/[accountId]/route.ts +++ b/src/app/api/v1/accounts/[accountId]/route.ts @@ -7,8 +7,29 @@ function isValidRole(value: string): value is "primary" | "backup" | "api_fallba return value === "primary" || value === "backup" || value === "api_fallback"; } -function isValidProvider(value: string): value is "master_codex_node" | "openai_api" | "aliyun_qwen_api" { - return value === "master_codex_node" || value === "openai_api" || value === "aliyun_qwen_api"; +function isValidProvider( + value: string, +): value is + | "master_codex_node" + | "google_oauth" + | "chatgpt_oauth" + | "openai_api" + | "aliyun_qwen_api" + | "minimax_api" + | "glm_api" + | "hyzq_api" + | "custom_api" { + return ( + value === "master_codex_node" || + value === "google_oauth" || + value === "chatgpt_oauth" || + value === "openai_api" || + value === "aliyun_qwen_api" || + value === "minimax_api" || + value === "glm_api" || + value === "hyzq_api" || + value === "custom_api" + ); } export async function GET( @@ -49,6 +70,7 @@ export async function PATCH( nodeId?: string; nodeLabel?: string; model?: string; + apiBaseUrl?: string; apiKey?: string; enabled?: boolean; setActive?: boolean; @@ -79,6 +101,7 @@ export async function PATCH( nodeId: body.nodeId, nodeLabel: body.nodeLabel, model: body.model, + apiBaseUrl: body.apiBaseUrl, apiKey: body.apiKey, enabled: body.enabled, setActive: body.setActive, diff --git a/src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts b/src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts index 8c25604..06a10b3 100644 --- a/src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts +++ b/src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts @@ -24,6 +24,7 @@ export async function POST(request: NextRequest) { displayName?: string; accountIdentifier?: string; model?: string; + apiBaseUrl?: string; apiKey?: string; }; @@ -39,6 +40,7 @@ export async function POST(request: NextRequest) { provider: "aliyun_qwen_api", apiKey: body.apiKey, model: body.model, + apiBaseUrl: body.apiBaseUrl, }); const state = await readState(); @@ -51,6 +53,7 @@ export async function POST(request: NextRequest) { displayName: body.displayName.trim(), accountIdentifier: body.accountIdentifier?.trim() || undefined, model: probe.model, + apiBaseUrl: body.apiBaseUrl, apiKey: body.apiKey.trim(), enabled: true, setActive: false, diff --git a/src/app/api/v1/accounts/onboard/openai-api/route.ts b/src/app/api/v1/accounts/onboard/openai-api/route.ts index b782cc7..f13191b 100644 --- a/src/app/api/v1/accounts/onboard/openai-api/route.ts +++ b/src/app/api/v1/accounts/onboard/openai-api/route.ts @@ -24,6 +24,7 @@ export async function POST(request: NextRequest) { displayName?: string; accountIdentifier?: string; model?: string; + apiBaseUrl?: string; apiKey?: string; }; @@ -38,6 +39,7 @@ export async function POST(request: NextRequest) { const probe = await probeOpenAiApiAccount({ apiKey: body.apiKey, model: body.model, + apiBaseUrl: body.apiBaseUrl, }); const state = await readState(); @@ -50,6 +52,7 @@ export async function POST(request: NextRequest) { displayName: body.displayName.trim(), accountIdentifier: body.accountIdentifier?.trim() || undefined, model: probe.model, + apiBaseUrl: body.apiBaseUrl, apiKey: body.apiKey.trim(), enabled: true, setActive: true, diff --git a/src/app/api/v1/accounts/route.ts b/src/app/api/v1/accounts/route.ts index 340d805..5bb6f2b 100644 --- a/src/app/api/v1/accounts/route.ts +++ b/src/app/api/v1/accounts/route.ts @@ -7,8 +7,29 @@ function isValidRole(value: string): value is "primary" | "backup" | "api_fallba return value === "primary" || value === "backup" || value === "api_fallback"; } -function isValidProvider(value: string): value is "master_codex_node" | "openai_api" | "aliyun_qwen_api" { - return value === "master_codex_node" || value === "openai_api" || value === "aliyun_qwen_api"; +function isValidProvider( + value: string, +): value is + | "master_codex_node" + | "google_oauth" + | "chatgpt_oauth" + | "openai_api" + | "aliyun_qwen_api" + | "minimax_api" + | "glm_api" + | "hyzq_api" + | "custom_api" { + return ( + value === "master_codex_node" || + value === "google_oauth" || + value === "chatgpt_oauth" || + value === "openai_api" || + value === "aliyun_qwen_api" || + value === "minimax_api" || + value === "glm_api" || + value === "hyzq_api" || + value === "custom_api" + ); } export async function GET(request: NextRequest) { @@ -38,6 +59,7 @@ export async function POST(request: NextRequest) { nodeId?: string; nodeLabel?: string; model?: string; + apiBaseUrl?: string; apiKey?: string; enabled?: boolean; setActive?: boolean; @@ -66,6 +88,7 @@ export async function POST(request: NextRequest) { nodeId: body.nodeId, nodeLabel: body.nodeLabel, model: body.model, + apiBaseUrl: body.apiBaseUrl, apiKey: body.apiKey, enabled: body.enabled, setActive: body.setActive, diff --git a/src/app/api/v1/accounts/validate-draft/route.ts b/src/app/api/v1/accounts/validate-draft/route.ts new file mode 100644 index 0000000..83ef7ae --- /dev/null +++ b/src/app/api/v1/accounts/validate-draft/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { validateAiAccountDraftConnection } from "@/lib/boss-master-agent"; + +function isValidProvider( + value: string, +): value is + | "openai_api" + | "aliyun_qwen_api" + | "minimax_api" + | "glm_api" + | "hyzq_api" + | "custom_api" { + return ( + value === "openai_api" || + value === "aliyun_qwen_api" || + value === "minimax_api" || + value === "glm_api" || + value === "hyzq_api" || + value === "custom_api" + ); +} + +export async function POST(request: NextRequest) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + if (session.role !== "highest_admin") { + return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 }); + } + + const body = (await request.json()) as { + provider?: string; + apiKey?: string; + apiBaseUrl?: string; + }; + + if (!body.provider || !isValidProvider(body.provider)) { + return NextResponse.json({ ok: false, message: "API 接入商不合法。" }, { status: 400 }); + } + if (!body.apiKey?.trim()) { + return NextResponse.json({ ok: false, message: "API Key 不能为空。" }, { status: 400 }); + } + + try { + const result = await validateAiAccountDraftConnection({ + provider: body.provider, + apiKey: body.apiKey, + apiBaseUrl: body.apiBaseUrl, + }); + return NextResponse.json(result, { status: 200 }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts index 24911c3..b25f337 100644 --- a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts +++ b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts @@ -56,6 +56,8 @@ export async function POST( const payload = body as { modelOverride?: unknown; reasoningEffortOverride?: unknown; + fastModelOverride?: unknown; + deepModelOverride?: unknown; promptOverride?: unknown; backendOverride?: unknown; takeoverEnabled?: unknown; @@ -66,6 +68,8 @@ export async function POST( payload, "reasoningEffortOverride", ); + const hasFastModelOverride = Object.prototype.hasOwnProperty.call(payload, "fastModelOverride"); + const hasDeepModelOverride = Object.prototype.hasOwnProperty.call(payload, "deepModelOverride"); const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride"); const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride"); const hasTakeoverEnabled = Object.prototype.hasOwnProperty.call(payload, "takeoverEnabled"); @@ -75,6 +79,8 @@ export async function POST( ? new Set([ "modelOverride", "reasoningEffortOverride", + "fastModelOverride", + "deepModelOverride", "promptOverride", "backendOverride", "globalTakeoverEnabled", @@ -85,6 +91,8 @@ export async function POST( ( !hasModelOverride && !hasReasoningEffortOverride && + !hasFastModelOverride && + !hasDeepModelOverride && !hasPromptOverride && !hasBackendOverride && !hasTakeoverEnabled && @@ -110,6 +118,12 @@ export async function POST( { status: 400 }, ); } + if (hasFastModelOverride && payload.fastModelOverride !== undefined && payload.fastModelOverride !== null && typeof payload.fastModelOverride !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_FAST_MODEL_OVERRIDE" }, { status: 400 }); + } + if (hasDeepModelOverride && payload.deepModelOverride !== undefined && payload.deepModelOverride !== null && typeof payload.deepModelOverride !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_DEEP_MODEL_OVERRIDE" }, { status: 400 }); + } if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") { return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 }); } @@ -154,6 +168,8 @@ export async function POST( { ...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}), ...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}), + ...(hasFastModelOverride ? { fastModelOverride: payload.fastModelOverride } : {}), + ...(hasDeepModelOverride ? { deepModelOverride: payload.deepModelOverride } : {}), ...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}), ...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}), ...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}), diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 8469611..0990971 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -1,6 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; -import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data"; +import { + appendProjectMessage, + appendProjectMessages, + buildCollaborationGate, + getProjectAgentControls, + readState, + requestProjectUnderstandingSyncForProject, +} from "@/lib/boss-data"; import { jsonNoStore } from "@/lib/api-response"; import { buildProjectMessagesRealtimePayload } from "@/lib/boss-projections"; import { @@ -10,6 +17,7 @@ import { replyToMasterAgentUserMessage, shouldRecommendMasterAgentDispatchPlan, ThreadConversationExecutionConflictError, + tryBuildLocalMasterAgentFastReply, } from "@/lib/boss-master-agent"; import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy"; @@ -105,14 +113,19 @@ export async function POST( ); } - const singleThreadExecutionConflict = - project && + const isSingleThreadTextMessage = + Boolean(project) && projectId !== "master-agent" && - !project.isGroup && + !project?.isGroup && (body.kind ?? "text") === "text" && - (body.body ?? "").trim().length > 0 - ? await getThreadConversationExecutionConflict(projectId) - : null; + (body.body ?? "").trim().length > 0; + const singleThreadAgentControls = isSingleThreadTextMessage + ? await getProjectAgentControls(projectId, session.account) + : null; + const singleThreadTakeoverEnabled = singleThreadAgentControls?.effectiveTakeoverEnabled === true; + const singleThreadExecutionConflict = isSingleThreadTextMessage && !singleThreadTakeoverEnabled + ? await getThreadConversationExecutionConflict(projectId) + : null; if (singleThreadExecutionConflict) { return NextResponse.json( @@ -126,6 +139,49 @@ export async function POST( ); } + if (projectId === "master-agent" && (body.kind ?? "text") === "text" && (body.body ?? "").trim()) { + const localMasterReply = await tryBuildLocalMasterAgentFastReply({ + requestText: (body.body ?? "").trim(), + requestedByAccount: session.account, + projectId, + state, + }); + if (localMasterReply) { + const [message, replyMessage] = await appendProjectMessages({ + projectId, + messages: [ + { + senderLabel: session.displayName || "你", + body: body.body, + kind: body.kind ?? "text", + }, + { + sender: "master", + senderLabel: localMasterReply.senderLabel, + body: localMasterReply.replyBody, + kind: "text", + }, + ], + }); + + return NextResponse.json({ + ok: true, + message, + replyMessage, + masterReply: localMasterReply.masterReply, + task: null, + replyPresenter: "master", + masterReplyState: "completed", + dispatchPlan: null, + dispatchRecommendation: { + ok: false, + status: "skipped", + }, + collaborationGate: buildCollaborationGate(project), + }); + } + } + const message = await appendProjectMessage({ projectId, senderLabel: session.displayName || "你", @@ -155,8 +211,10 @@ export async function POST( taskType: "conversation_reply"; status: "queued" | "running" | "completed"; }; + replyMessage?: Awaited>; } | undefined; + let replyMessage: Awaited> | undefined; let task: | { taskId: string; @@ -169,6 +227,7 @@ export async function POST( | "running" | "completed" | null = null; + let replyPresenter: "thread" | "master" | undefined; if (shouldCreateDispatchPlan) { try { @@ -204,18 +263,49 @@ export async function POST( }); } } else if (project && projectId !== "master-agent" && !project.isGroup && message.body.trim().length > 0) { - const queuedTask = await queueThreadConversationReplyTask({ - projectId, - requestMessageId: message.id, - requestText: message.body, - requestedBy: session.displayName || session.account, - requestedByAccount: session.account, - }); - task = { - taskId: queuedTask.taskId, - taskType: "conversation_reply", - status: "queued", - }; + const relayViaMasterAgent = singleThreadTakeoverEnabled; + if (relayViaMasterAgent) { + if (shouldRequestVerifiedProjectSummarySync(message.body)) { + await requestProjectUnderstandingSyncForProject({ + projectId, + observedActivityAt: message.sentAt, + reason: "thread_reply", + }); + } + masterReply = await replyToMasterAgentUserMessage({ + requestMessageId: message.id, + requestText: message.body, + requestedBy: session.displayName || session.account, + requestedByAccount: session.account, + currentSessionExpiresAt: session.expiresAt, + projectId, + interactionMode: "takeover_single_thread", + mode: "enqueue", + }) + if (masterReply?.taskId) { + task = masterReply.task ?? { + taskId: masterReply.taskId, + taskType: "conversation_reply", + status: masterReply.masterReplyState ?? "queued", + }; + masterReplyState = masterReply.masterReplyState ?? null; + } + replyMessage = masterReply?.replyMessage; + } else { + const queuedTask = await queueThreadConversationReplyTask({ + projectId, + requestMessageId: message.id, + requestText: message.body, + requestedBy: session.displayName || session.account, + requestedByAccount: session.account, + }); + task = { + taskId: queuedTask.taskId, + taskType: "conversation_reply", + status: "queued", + }; + } + replyPresenter = relayViaMasterAgent ? "master" : "thread"; } else { dispatchRecommendation = { ok: false, @@ -230,11 +320,19 @@ export async function POST( requestedBy: session.displayName, requestedByAccount: session.account, currentSessionExpiresAt: session.expiresAt, - mode: "enqueue", + mode: "smart", }); - if (masterReply?.ok && masterReply.taskId) { - task = masterReply.task ?? null; - masterReplyState = masterReply.masterReplyState ?? null; + if (masterReply?.ok) { + if (masterReply.taskId) { + task = masterReply.task ?? { + taskId: masterReply.taskId, + taskType: "conversation_reply", + status: masterReply.masterReplyState ?? "queued", + }; + } + masterReplyState = masterReply.masterReplyState ?? (masterReply.taskId ? null : "completed"); + replyPresenter = "master"; + replyMessage = masterReply.replyMessage; } else { masterReplyState = null; } @@ -247,8 +345,10 @@ export async function POST( return NextResponse.json({ ok: true, message, + replyMessage, masterReply, task, + replyPresenter, masterReplyState, dispatchPlan, dispatchRecommendation, @@ -277,3 +377,14 @@ export async function POST( ); } } + +function shouldRequestVerifiedProjectSummarySync(text: string) { + const normalized = text.trim(); + if (!normalized) { + return false; + } + const mentionsGoal = /项目目标|目标/.test(normalized); + const mentionsVersion = /版本记录|版本迭代|版本/.test(normalized); + const mentionsReviewOrSync = /核对|确认|同步|更新|刷新|整理|汇总/.test(normalized); + return mentionsReviewOrSync && (mentionsGoal || mentionsVersion); +} diff --git a/src/app/conversations/[projectId]/goals/page.tsx b/src/app/conversations/[projectId]/goals/page.tsx index 1bd01e1..e1d302e 100644 --- a/src/app/conversations/[projectId]/goals/page.tsx +++ b/src/app/conversations/[projectId]/goals/page.tsx @@ -23,6 +23,15 @@ export default async function GoalsPage({ if (!project) notFound(); const completedCount = project.goals.filter((item) => item.state === "completed").length; + const understandingUpdatedAt = project.projectUnderstanding?.updatedAt + ? new Date(project.projectUnderstanding.updatedAt).toLocaleString("zh-CN", { + hour12: false, + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : null; return ( @@ -42,6 +51,34 @@ export default async function GoalsPage({ 最近更新 09:18 · 用户可编辑 · 点选圆圈标记完成后自动划线 + {project.projectUnderstanding ? ( +
+
+
同步项目摘要
+
{understandingUpdatedAt ?? "刚刚更新"}
+
+
+
+
项目目标
+
+ {project.projectUnderstanding?.projectGoal} +
+
+
+
当前进度
+
+ {project.projectUnderstanding?.currentProgress} +
+
+
+
建议下一步
+
+ {project.projectUnderstanding?.recommendedNextStep} +
+
+
+
+ ) : null}
当前约束
diff --git a/src/app/conversations/[projectId]/versions/page.tsx b/src/app/conversations/[projectId]/versions/page.tsx index 2974ad4..26b5851 100644 --- a/src/app/conversations/[projectId]/versions/page.tsx +++ b/src/app/conversations/[projectId]/versions/page.tsx @@ -26,10 +26,10 @@ export default async function VersionsPage({ - +
版本记录由主 Agent 监督各线程提交,并在复核后自动发布。当前页面只读,不允许人工直接篡改正文。 diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index 76b10d4..177e695 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -25,6 +25,7 @@ import type { ThreadConversationExecutionConflict, ThreadConversationExecutionConflictAction, } from "@/lib/thread-execution-conflict"; +import { parseChatMarkdown, type ChatMarkdownBlock } from "@/lib/chat-markdown"; import { describeThreadConversationExecutionConflict, labelForProjectConflictAllowPolicy, @@ -907,13 +908,101 @@ export function ChatBubble({ message }: { message: Message }) { {tag ? (
{tag}
) : null} - {message.body} +
); } +function ChatBubbleMarkdown({ + body, + mine, + green, +}: { + body: string; + mine: boolean; + green: boolean; +}) { + const blocks = parseChatMarkdown(body); + + return ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ); +} + +function ChatMarkdownBlockView({ + block, + mine, + green, +}: { + block: ChatMarkdownBlock; + mine: boolean; + green: boolean; +}) { + const mutedClass = mine ? "text-white/82" : green ? "text-[#4E7A60]" : "text-[#57606A]"; + const markerClass = mine ? "text-white/72" : green ? "text-[#44A064]" : "text-[#8C8C8C]"; + + switch (block.kind) { + case "heading": + return ( +
+ {block.text} +
+ ); + case "label": + return ( +
+
{block.label}
+
{block.text}
+
+ ); + case "bullet": + return ( +
+ + {block.text} +
+ ); + case "ordered": + return ( +
+ {block.order} + {block.text} +
+ ); + case "quote": + return ( +
+ {block.text} +
+ ); + case "code": + return ( +
+          {block.text}
+        
+ ); + case "paragraph": + default: + return
{block.text}
; + } +} + export function ProjectHeaderActions({ projectId }: { projectId: string }) { return (
diff --git a/src/components/master-agent-prompt-memory-client.tsx b/src/components/master-agent-prompt-memory-client.tsx index 8ee1187..462f0b6 100644 --- a/src/components/master-agent-prompt-memory-client.tsx +++ b/src/components/master-agent-prompt-memory-client.tsx @@ -12,6 +12,7 @@ import type { UserMasterPrompt, } from "@/lib/boss-data"; import type { MasterAgentChatPageAnchors } from "@/lib/master-agent-chat-menu"; +import { getMasterAgentModelOptions } from "@/lib/master-agent-model-options"; import { formatTimestampLabel } from "@/lib/boss-projections"; type MemoryDraft = { @@ -191,6 +192,7 @@ export function MasterAgentPromptMemoryClient({ }); const allMemories = useMemo(() => [...projectMemories, ...globalMemories], [projectMemories, globalMemories]); + const modelOptions = useMemo(() => getMasterAgentModelOptions(modelOverride), [modelOverride]); const promptPreview = useMemo(() => { const sections = [ globalPrompt.trim() ? `【管理员全局主提示词】\n${globalPrompt.trim()}` : null, @@ -431,9 +433,11 @@ export function MasterAgentPromptMemoryClient({ className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none" > - - - + {modelOptions.map((option) => ( + + ))}