From 9c02ebb574539265e32efd38bc91feba836707fd Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 31 Mar 2026 03:31:22 +0800 Subject: [PATCH] feat: complete chat routing and openai onboarding --- README.md | 13 + .../com/hyzq/boss/AiAccountsActivity.java | 225 ++++++++++++- .../java/com/hyzq/boss/BossApiClient.java | 36 ++- .../com/hyzq/boss/ProjectChatUiState.java | 59 ++++ .../com/hyzq/boss/ProjectDetailActivity.java | 190 +++++++++-- .../boss/BossApiClientDispatchPlansTest.java | 126 +++++++- .../com/hyzq/boss/ProjectChatUiStateTest.java | 55 ++++ ...ojectDetailActivityChromeBindingsTest.java | 9 + .../api_and_service_inventory_cn.md | 40 ++- .../current_runtime_and_deploy_status_cn.md | 13 + .../plans/2026-03-31-chat-path-completion.md | 66 ++++ .../2026-03-31-chat-path-completion-design.md | 162 ++++++++++ local-agent/codex-task-runner.mjs | 51 +++ local-agent/server.mjs | 25 +- .../v1/accounts/onboard/master-node/route.ts | 75 +++++ .../v1/accounts/onboard/openai-api/route.ts | 82 +++++ .../v1/projects/[projectId]/messages/route.ts | 65 +++- src/components/ai-accounts-client.tsx | 3 +- src/lib/boss-data.ts | 74 ++++- src/lib/boss-master-agent.ts | 295 +++++++++++++++--- tests/ai-account-onboarding.test.ts | 190 +++++++++++ tests/group-message-dispatch-plan.test.ts | 170 ++++++++-- tests/local-agent-codex-task-runner.test.mjs | 93 ++++++ tests/master-agent-openai-fallback.test.ts | 102 ++++++ tests/single-thread-message-execution.test.ts | 155 +++++++++ 25 files changed, 2241 insertions(+), 133 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-31-chat-path-completion.md create mode 100644 docs/superpowers/specs/2026-03-31-chat-path-completion-design.md create mode 100644 local-agent/codex-task-runner.mjs create mode 100644 src/app/api/v1/accounts/onboard/master-node/route.ts create mode 100644 src/app/api/v1/accounts/onboard/openai-api/route.ts create mode 100644 tests/ai-account-onboarding.test.ts create mode 100644 tests/local-agent-codex-task-runner.test.mjs create mode 100644 tests/master-agent-openai-fallback.test.ts create mode 100644 tests/single-thread-message-execution.test.ts diff --git a/README.md b/README.md index 2409be1..3c96ffd 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,14 @@ - `GET http://127.0.0.1:3000/api/v1/projects/master-agent` 正常,主 Agent 项目页已能显示最近 APP 日志 - `GET http://127.0.0.1:3000/api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾账号摘要 - `POST http://127.0.0.1:3000/api/v1/accounts/master-codex-primary/validate` 正常,已验证会明确提示“主 GPT 不在手机里直接登录”,并校验绑定设备在线状态 +- `POST http://127.0.0.1:3102/api/v1/accounts/onboard/master-node` 正常,已验证会保存 Master Codex Node 绑定信息并返回显式登录指引 +- `POST http://127.0.0.1:3102/api/v1/accounts/onboard/openai-api` 正常,已验证会对 API Key 做真实 OpenAI 探针校验;无效 Key 会返回真实错误 - `GET http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` 正常,已返回本机同步 Skill 列表 - `POST http://127.0.0.1:3000/api/auth/login` 正常,会写入 `boss_session` Cookie - `GET http://127.0.0.1:3000/api/auth/session` 正常 - `POST http://127.0.0.1:3000/api/auth/restore` 正常,已验证可用原生 restore token 恢复登录态 - `POST http://127.0.0.1:3000/api/v1/projects/master-agent/messages` 正常,已验证可通过 `Mac Studio local-agent -> 本机 Master Codex Node -> 回写项目账本` 返回真实主 Agent 回复 +- `POST http://127.0.0.1:3000/api/v1/projects/[projectId]/messages` 正常,普通单线程会话当前会返回 `conversation_reply` 任务,并等待绑定设备上的真实 Codex 线程回写 - `POST http://127.0.0.1:3000/api/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401` - `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包 - `GET http://127.0.0.1:4317/health` 正常 @@ -103,6 +106,9 @@ Android APK: - 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页 - 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页 - 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总 +- 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示 +- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号` 与 `绑定 Master Codex Node` 两条显式入口;OpenAI API 登录成功后会立即设为当前主控 +- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,避免聊天直接掉成失败日志 - 当前 Web 群聊页也已补上待确认推荐的刷新恢复:群聊详情会在服务端读取最近一条 `pending_user_confirmation` 的 dispatch plan,并在刷新或重新进入页面后继续显示“等待你确认主 Agent 推荐” - 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口 - 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入` @@ -183,7 +189,10 @@ device-agent 当前职责: - 递归扫描本机 `~/.codex/skills`,并同步到云端 `/api/v1/devices/[deviceId]/skills` - 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务 - 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete` +- 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口 - 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本 +- `local-agent` 对 `conversation_reply / dispatch_execution` 当前会优先使用 `codex exec resume `,只有缺失真实线程引用时才退回 `--ephemeral` +- 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员 - 设备导入审核当前也会落 `device_import_resolution` 任务轨迹,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解 - 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat` @@ -281,6 +290,10 @@ npm run aab:release - 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句 - 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node` - `AI 账号` 页面当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;主 GPT 的登录动作必须在绑定电脑上的 Codex / ChatGPT Plus 会话里完成,再回手机端点“测试连接 / 校验连接” +- `AI 账号` 页面当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号` 和 `绑定电脑上的 Codex 节点` +- `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即创建/更新 `openai_api` 主账号,并设为当前主控 +- `绑定电脑上的 Codex 节点` 当前会创建/更新 `master_codex_node` 主账号,并可直接设为当前主控;同时会返回“登录发生在绑定设备上”的明确中文指引 +- 当前公网服务器对 `api.openai.com` 仍存在出网阻塞;`OpenAI API Key` 登录入口已经实现,但在服务器恢复出网前,公网校验会返回明确的中文网络错误,建议先切回 `Master Codex Node` - `POST /api/v1/accounts/[accountId]/validate` 当前不再只看 `nodeId`;对 `master_codex_node` 会同时校验绑定设备是否在线,并在设备离线时返回明确的降级说明 - API 容灾当前不走服务器预置 Key,而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号 - 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除 diff --git a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java index bd63c60..629fcd2 100644 --- a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java @@ -1,8 +1,10 @@ package com.hyzq.boss; import android.os.Bundle; +import android.text.InputType; import android.widget.ArrayAdapter; import android.widget.Button; +import android.widget.EditText; import android.widget.LinearLayout; import android.widget.Spinner; @@ -22,7 +24,7 @@ public class AiAccountsActivity extends BossScreenActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - configureScreen("AI 账号", "主 GPT / 备用 GPT / API 容灾"); + configureScreen("AI 账号", "OpenAI API / Master Codex Node"); setHeaderAction("新增", v -> openAccountEditor(null, null)); replaceContent(); reload(); @@ -53,11 +55,12 @@ public class AiAccountsActivity extends BossScreenActivity { this, "AI 账号", "这里统一管理主 GPT、备用 GPT 与 API 容灾账号。", - "主 GPT 的登录发生在绑定设备上的 Codex / ChatGPT Plus,会在这里给登录指引。", + "OpenAI API 可以在手机直接登录;Master Codex Node 仍然在绑定设备上完成登录。", null, null )); appendContent(buildActiveIdentityCard(activeIdentity)); + appendContent(buildOnboardingEntrySection()); appendContent(buildAccountsSection(accounts)); setRefreshing(false); } @@ -114,6 +117,31 @@ public class AiAccountsActivity extends BossScreenActivity { return section; } + private LinearLayout buildOnboardingEntrySection() { + LinearLayout section = new LinearLayout(this); + section.setOrientation(LinearLayout.VERTICAL); + + section.addView(BossUi.buildWechatMenuRow( + this, + "登录 OpenAI 平台账号", + "填写 API Key 后直接设为当前主控。", + "适合手机端直连主 Agent。", + "推荐", + v -> openOpenAiOnboardingDialog() + )); + + section.addView(BossUi.buildWechatMenuRow( + this, + "绑定电脑上的 Codex 节点", + "把这台 Mac 上的 Codex / ChatGPT Plus 节点接回主 Agent。", + "登录发生在绑定设备上。", + null, + v -> openMasterNodeOnboardingDialog() + )); + + return section; + } + private LinearLayout buildAccountCard(JSONObject account) { String statusLabel = account.optString("statusLabel", account.optString("status", "-")); String meta = account.optString("roleLabel", "-") @@ -181,6 +209,199 @@ public class AiAccountsActivity extends BossScreenActivity { .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)); + + 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() + )) + .show(); + } + + private void openMasterNodeOnboardingDialog() { + final EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false); + labelInput.setText("主 GPT"); + 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 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()); + + String accountId = extractAccountId(response.json); + if (accountId.isEmpty()) { + runOnUiThread(() -> { + showMessage("OpenAI 平台账号已登录,并设为当前主控。"); + 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 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 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); 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 ad70638..ff32e58 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -29,7 +29,7 @@ import java.util.Map; public class BossApiClient { private static final int DEFAULT_CONNECT_TIMEOUT_MS = 12000; private static final int DEFAULT_READ_TIMEOUT_MS = 12000; - private static final int MASTER_AGENT_READ_TIMEOUT_MS = 65000; + private static final int CHAT_FLOW_READ_TIMEOUT_MS = 65000; private static final String PREFS_NAME = "boss_native_client"; private static final String KEY_SESSION_COOKIE = "session_cookie"; private static final String KEY_RESTORE_TOKEN = "restore_token"; @@ -103,10 +103,12 @@ public class BossApiClient { "approvedTargetProjectIds", approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds ); - return requestWithRestore( + return requestWithRestoreRaw( "POST", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm", - payload + payload.toString(), + DEFAULT_CONNECT_TIMEOUT_MS, + CHAT_FLOW_READ_TIMEOUT_MS ); } @@ -133,13 +135,12 @@ public class BossApiClient { JSONObject payload = new JSONObject(); payload.put("body", body); payload.put("kind", kind); - int readTimeoutMs = "master-agent".equals(projectId) ? MASTER_AGENT_READ_TIMEOUT_MS : DEFAULT_READ_TIMEOUT_MS; return requestWithRestoreRaw( "POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload.toString(), DEFAULT_CONNECT_TIMEOUT_MS, - readTimeoutMs + CHAT_FLOW_READ_TIMEOUT_MS ); } @@ -312,6 +313,14 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject()); } + public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException { + return onboardAccount("/api/v1/accounts/onboard/openai-api", payload); + } + + public ApiResponse onboardMasterNodeAccount(JSONObject payload) throws IOException, JSONException { + return onboardAccount("/api/v1/accounts/onboard/master-node", payload); + } + public ApiResponse getOpsSummary() throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/ops/summary", null); } @@ -413,6 +422,23 @@ public class BossApiClient { return response; } + private ApiResponse onboardAccount(String onboardPath, JSONObject payload) throws IOException, JSONException { + JSONObject normalized = payload == null ? new JSONObject() : new JSONObject(payload.toString()); + normalized.put("setActive", true); + ApiResponse response = requestWithRestore("POST", onboardPath, normalized); + if (response.statusCode != 404) { + return response; + } + + JSONObject fallbackPayload = new JSONObject(normalized.toString()); + String accountId = fallbackPayload.optString("accountId", ""); + if (!accountId.isEmpty()) { + return updateAccount(accountId, fallbackPayload); + } + fallbackPayload.remove("accountId"); + return createAccount(fallbackPayload); + } + private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException { return requestRaw( method, diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index 9ffc3bb..6c0ed7c 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -59,6 +59,16 @@ public final class ProjectChatUiState { } } + public static final class ReplyWaitSpec { + public final boolean shouldWait; + public final String baselineMessageId; + + private ReplyWaitSpec(boolean shouldWait, @Nullable String baselineMessageId) { + this.shouldWait = shouldWait && !isBlank(baselineMessageId); + this.baselineMessageId = this.shouldWait ? baselineMessageId.trim() : ""; + } + } + public static boolean canSend(String text, boolean sending) { return !sending && text != null && !text.trim().isEmpty(); } @@ -295,6 +305,55 @@ public final class ProjectChatUiState { return Math.max(fileSizeBytes, 0L) + " B"; } + public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) { + if (response == null) { + return new ReplyWaitSpec(false, null); + } + JSONObject task = response.optJSONObject("task"); + if (task == null) { + return new ReplyWaitSpec(false, null); + } + String taskStatus = task.optString("status", ""); + if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) { + return new ReplyWaitSpec(false, null); + } + JSONObject message = response.optJSONObject("message"); + return new ReplyWaitSpec(true, message == null ? null : message.optString("id", "")); + } + + public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) { + if (response == null) { + return new ReplyWaitSpec(false, null); + } + JSONArray executions = response.optJSONArray("executions"); + if (executions == null || executions.length() == 0) { + return new ReplyWaitSpec(false, null); + } + JSONObject notice = response.optJSONObject("notice"); + return new ReplyWaitSpec(true, notice == null ? null : notice.optString("id", "")); + } + + public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) { + if (project == null || isBlank(baselineMessageId)) { + return false; + } + String latestMessageId = latestMessageId(project.optJSONArray("messages")); + return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId); + } + + @Nullable + public static String latestMessageId(@Nullable JSONArray messages) { + if (messages == null || messages.length() == 0) { + return null; + } + JSONObject latestMessage = messages.optJSONObject(messages.length() - 1); + if (latestMessage == null) { + return null; + } + String messageId = latestMessage.optString("id", "").trim(); + return messageId.isEmpty() ? null : messageId; + } + private static boolean isBlank(@Nullable String value) { return value == null || value.trim().isEmpty(); } 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 10cba07..e080fe7 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,8 @@ import java.util.List; public class ProjectDetailActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_NAME = "project_name"; + private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L; + private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L; private String projectId; private String initialProjectName; @@ -106,6 +108,16 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private static final class ProjectSnapshot { + final JSONObject payload; + final @Nullable JSONArray dispatchPlans; + + ProjectSnapshot(JSONObject payload, @Nullable JSONArray dispatchPlans) { + this.payload = payload; + this.dispatchPlans = dispatchPlans; + } + } + @Override protected int getLayoutResId() { return R.layout.activity_project_chat; @@ -215,24 +227,8 @@ public class ProjectDetailActivity extends BossScreenActivity { setRefreshing(true); executor.execute(() -> { try { - BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId); - if (!response.ok()) { - throw new IllegalStateException(response.message()); - } - JSONObject project = response.json.optJSONObject("project"); - JSONArray dispatchPlans = null; - if (project != null && project.optBoolean("isGroup", false)) { - try { - BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId); - if (dispatchPlansResponse.ok()) { - dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans"); - } - } catch (Exception ignored) { - dispatchPlans = null; - } - } - JSONArray finalDispatchPlans = dispatchPlans; - runOnUiThread(() -> renderProject(response.json, finalDispatchPlans)); + ProjectSnapshot snapshot = fetchProjectSnapshot(); + runOnUiThread(() -> renderProject(snapshot.payload, snapshot.dispatchPlans)); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -461,8 +457,9 @@ public class ProjectDetailActivity extends BossScreenActivity { } JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan"); JSONObject collaborationGate = response.json.optJSONObject("collaborationGate"); + ProjectChatUiState.ReplyWaitSpec waitSpec = + ProjectChatUiState.resolveReplyWaitAfterSend(response.json); runOnUiThread(() -> { - composerSending = false; composerInput.setText(""); if (collaborationGate != null) { projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode); @@ -470,18 +467,25 @@ public class ProjectDetailActivity extends BossScreenActivity { } currentPendingDispatchPlan = dispatchPlan; if (dispatchPlan != null) { + composerSending = false; + updateComposerSendButtonState(); showMessage( "approval_required".equals(projectCollaborationMode) ? "消息已发送,等待你批准主 Agent 下发。" : "消息已发送,主 Agent 已给出推荐线程。" ); - } else { - showMessage("消息已发送"); - } - reload(true); - if (dispatchPlan != null) { + reload(true); showDispatchPlanConfirmation(dispatchPlan); + return; } + if (waitSpec.shouldWait) { + startReplyWait(waitSpec, false, "消息已发送,正在等待回复…"); + return; + } + composerSending = false; + updateComposerSendButtonState(); + showMessage("消息已发送"); + reload(true); }); } catch (Exception error) { runOnUiThread(() -> { @@ -577,7 +581,7 @@ public class ProjectDetailActivity extends BossScreenActivity { this, "approval_required".equals(projectCollaborationMode) ? "等待你批准主 Agent 下发" : "主 Agent 推荐下发", ProjectChatUiState.summarizeDispatchPlan(dispatchPlan), - "当前确认状态:" + projectApprovalState + "当前确认状态:" + describeDispatchPlanApprovalState(projectApprovalState) )); Button confirmButton = BossUi.buildMiniActionButton(this, "确认下发", true); confirmButton.setOnClickListener(v -> showDispatchPlanConfirmation(dispatchPlan)); @@ -623,9 +627,18 @@ public class ProjectDetailActivity extends BossScreenActivity { } JSONArray executions = response.json.optJSONArray("executions"); int executionCount = executions == null ? approvedTargetProjectIds.size() : executions.length(); + ProjectChatUiState.ReplyWaitSpec waitSpec = + ProjectChatUiState.resolveReplyWaitAfterDispatchConfirm(response.json); runOnUiThread(() -> { - currentPendingDispatchPlan = null; projectApprovalState = "approval_required".equals(projectCollaborationMode) ? "approved" : "not_required"; + if (waitSpec.shouldWait) { + startReplyWait( + waitSpec, + true, + "已确认下发到 " + executionCount + " 个线程,正在等待线程回复…" + ); + return; + } showMessage("已确认下发到 " + executionCount + " 个线程"); reload(true); }); @@ -1463,6 +1476,111 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private ProjectSnapshot fetchProjectSnapshot() throws Exception { + BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + JSONObject project = response.json.optJSONObject("project"); + boolean includeDispatchPlans = project != null && project.optBoolean("isGroup", false); + return fetchProjectSnapshot(includeDispatchPlans, response); + } + + private ProjectSnapshot fetchProjectSnapshot(boolean includeDispatchPlans) throws Exception { + return fetchProjectSnapshot(includeDispatchPlans, null); + } + + private ProjectSnapshot fetchProjectSnapshot( + boolean includeDispatchPlans, + @Nullable BossApiClient.ApiResponse existingDetailResponse + ) throws Exception { + BossApiClient.ApiResponse detailResponse = existingDetailResponse == null + ? apiClient.getProjectDetail(projectId) + : existingDetailResponse; + if (!detailResponse.ok()) { + throw new IllegalStateException(detailResponse.message()); + } + JSONArray dispatchPlans = null; + if (includeDispatchPlans) { + try { + BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId); + if (dispatchPlansResponse.ok()) { + dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans"); + } + } catch (Exception ignored) { + dispatchPlans = null; + } + } + return new ProjectSnapshot(detailResponse.json, dispatchPlans); + } + + private void startReplyWait( + ProjectChatUiState.ReplyWaitSpec waitSpec, + boolean includeDispatchPlans, + String waitingMessage + ) { + composerSending = true; + updateComposerSendButtonState(); + setRefreshing(true); + showMessage(waitingMessage); + executor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans)); + } + + private void pollUntilReply( + ProjectChatUiState.ReplyWaitSpec waitSpec, + boolean includeDispatchPlans + ) { + long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS; + boolean renderedInitialSnapshot = false; + try { + while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) { + ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans); + JSONObject project = snapshot.payload.optJSONObject("project"); + boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, waitSpec.baselineMessageId); + + if (!renderedInitialSnapshot || hasReply) { + runOnUiThread(() -> { + renderProject(snapshot.payload, snapshot.dispatchPlans); + if (!hasReply) { + composerSending = true; + updateComposerSendButtonState(); + setRefreshing(true); + } + }); + renderedInitialSnapshot = true; + } + + if (hasReply) { + runOnUiThread(() -> { + composerSending = false; + updateComposerSendButtonState(); + setRefreshing(false); + scrollChatToBottom(); + }); + return; + } + + Thread.sleep(REPLY_WAIT_POLL_INTERVAL_MS); + } + + runOnUiThread(() -> { + composerSending = false; + updateComposerSendButtonState(); + setRefreshing(false); + showMessage("对方还在处理中,稍后下拉刷新查看最新回复。"); + reload(false); + }); + } catch (Exception error) { + runOnUiThread(() -> { + composerSending = false; + updateComposerSendButtonState(); + setRefreshing(false); + showMessage("等待回复失败:" + error.getMessage()); + reload(false); + }); + } + } + static ChromeBindings buildChromeBindings( ProjectChatUiState.ChromeState chromeState, boolean composerBusy @@ -1481,6 +1599,26 @@ public class ProjectDetailActivity extends BossScreenActivity { ); } + static String describeDispatchPlanApprovalState(@Nullable String approvalState) { + if (approvalState == null || approvalState.trim().isEmpty()) { + return "状态未知"; + } + switch (approvalState) { + case "pending_user": + return "待确认"; + case "pending_agent": + return "等待主 Agent 处理"; + case "approved": + return "已确认,等待线程回流"; + case "rejected": + return "已拒绝"; + case "not_required": + return "无需确认"; + default: + return approvalState; + } + } + private List collectMessageIds(@Nullable JSONArray messages) { ArrayList ids = new ArrayList<>(); if (messages == 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 01e59dc..158fca7 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -49,6 +49,8 @@ public class BossApiClientDispatchPlansTest { assertEquals(200, response.statusCode); assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/confirm", apiClient.lastPath); assertEquals("POST", connection.requestMethodValue); + assertEquals(12000, connection.connectTimeoutValue); + assertEquals(65000, connection.readTimeoutValue); assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody()); } @@ -66,6 +68,71 @@ public class BossApiClientDispatchPlansTest { assertEquals(65000, connection.readTimeoutValue); } + @Test + public void sendProjectMessageUsesExtendedReadTimeoutForNormalThread() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.sendProjectMessage("thread-1", "你好", "text"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals(12000, connection.connectTimeoutValue); + assertEquals(65000, connection.readTimeoutValue); + } + + @Test + public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + JSONObject payload = new JSONObject() + .put("label", "主 GPT") + .put("displayName", "OpenAI 平台账号") + .put("accountIdentifier", "sk-test") + .put("model", "gpt-5.4") + .put("apiKey", "sk-test-key"); + + BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/accounts/onboard/openai-api", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals( + "{\"label\":\"主 GPT\",\"displayName\":\"OpenAI 平台账号\",\"accountIdentifier\":\"sk-test\",\"model\":\"gpt-5.4\",\"apiKey\":\"sk-test-key\",\"setActive\":true}", + connection.requestBody() + ); + } + + @Test + public void onboardMasterNodeFallsBackToGenericAccountCreationWhenDedicatedRouteMissing() throws Exception { + RecordingConnection dedicated = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/accounts/onboard/master-node"), + 404, + "{\"ok\":false,\"message\":\"NOT_FOUND\"}", + "{\"ok\":false,\"message\":\"NOT_FOUND\"}" + ); + RecordingConnection fallback = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts")); + ScriptedBossApiClient apiClient = new ScriptedBossApiClient(dedicated, fallback); + JSONObject payload = new JSONObject() + .put("label", "主 GPT") + .put("displayName", "Mac Studio") + .put("accountIdentifier", "mac-studio") + .put("nodeId", "mac-studio") + .put("nodeLabel", "Mac Studio") + .put("model", "gpt-5.4"); + + BossApiClient.ApiResponse response = apiClient.onboardMasterNodeAccount(payload); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/accounts", apiClient.lastPath); + assertEquals("POST", fallback.requestMethodValue); + assertEquals( + "{\"label\":\"主 GPT\",\"displayName\":\"Mac Studio\",\"accountIdentifier\":\"mac-studio\",\"nodeId\":\"mac-studio\",\"nodeLabel\":\"Mac Studio\",\"model\":\"gpt-5.4\",\"setActive\":true}", + fallback.requestBody() + ); + } + private static final class RecordingBossApiClient extends BossApiClient { private final RecordingConnection connection; private String lastPath = ""; @@ -92,15 +159,58 @@ public class BossApiClientDispatchPlansTest { } } + private static final class ScriptedBossApiClient extends BossApiClient { + private final Map connections; + private String lastPath = ""; + + ScriptedBossApiClient(RecordingConnection... connections) { + super(new InMemorySharedPreferences(), "https://boss.hyzq.net"); + this.connections = new HashMap<>(); + for (RecordingConnection connection : connections) { + this.connections.put(connection.url().getPath(), connection); + } + } + + @Override + HttpURLConnection openConnection(String path) { + lastPath = path; + RecordingConnection connection = connections.get(path); + if (connection == null) { + throw new IllegalStateException("Missing scripted connection for " + path); + } + return connection; + } + + @Override + String encode(String value) { + return value; + } + + @Override + void rememberIdentity(JSONObject json) { + // no-op for JVM unit test + } + } + private static final class RecordingConnection extends HttpURLConnection { private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); private final Map requestHeaders = new HashMap<>(); private String requestMethodValue = "GET"; private int connectTimeoutValue = 0; private int readTimeoutValue = 0; + private final int responseCodeValue; + private final String responseBody; + private final String errorBody; RecordingConnection(URL url) { + this(url, 200, "{\"ok\":true}", "{\"ok\":false}"); + } + + RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) { super(url); + this.responseCodeValue = responseCodeValue; + this.responseBody = responseBody; + this.errorBody = errorBody; } @Override @@ -146,17 +256,29 @@ public class BossApiClientDispatchPlansTest { @Override public int getResponseCode() { - return 200; + return responseCodeValue; } @Override public InputStream getInputStream() { - return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8)); + return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public InputStream getErrorStream() { + if (responseCodeValue < 400) { + return null; + } + return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8)); } String requestBody() { return requestBody.toString(StandardCharsets.UTF_8); } + + URL url() { + return getURL(); + } } private static final class InMemorySharedPreferences implements SharedPreferences { diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java index 63b1458..fb107be 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java @@ -194,4 +194,59 @@ public class ProjectChatUiStateTest { assertEquals(List.of("p2", "p1"), approvedTargetIds); } + + @Test + public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception { + JSONObject response = new JSONObject() + .put("message", new JSONObject().put("id", "msg-user-1")) + .put("task", new JSONObject() + .put("taskId", "task-1") + .put("taskType", "conversation_reply") + .put("status", "queued")); + + ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response); + + assertTrue(waitSpec.shouldWait); + assertEquals("msg-user-1", waitSpec.baselineMessageId); + } + + @Test + public void completedReplyTaskDoesNotStartReplyWait() throws Exception { + JSONObject response = new JSONObject() + .put("message", new JSONObject().put("id", "msg-user-1")) + .put("task", new JSONObject() + .put("taskId", "task-1") + .put("taskType", "conversation_reply") + .put("status", "completed")); + + ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response); + + assertFalse(waitSpec.shouldWait); + assertEquals("", waitSpec.baselineMessageId); + } + + @Test + public void dispatchConfirmWaitsFromNoticeMessageId() throws Exception { + JSONObject response = new JSONObject() + .put("notice", new JSONObject().put("id", "msg-notice-1")) + .put("executions", new JSONArray() + .put(new JSONObject().put("executionId", "exec-1"))); + + ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterDispatchConfirm(response); + + assertTrue(waitSpec.shouldWait); + assertEquals("msg-notice-1", waitSpec.baselineMessageId); + } + + @Test + public void replyWaitSatisfiedOnlyAfterLatestMessageMovesPastBaseline() throws Exception { + JSONObject project = new JSONObject() + .put("messages", new JSONArray() + .put(new JSONObject().put("id", "msg-user-1")) + .put(new JSONObject().put("id", "msg-thread-1"))); + + assertTrue(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1")); + assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-thread-1")); + assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "")); + } } diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityChromeBindingsTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityChromeBindingsTest.java index 7652898..98fbec3 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityChromeBindingsTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityChromeBindingsTest.java @@ -54,4 +54,13 @@ public class ProjectDetailActivityChromeBindingsTest { assertEquals("北区试产线回归", bindings.title); assertEquals("归档确认", bindings.subtitle); } + + @Test + public void describeDispatchPlanApprovalStateUsesUserFacingLabels() { + assertEquals("待确认", ProjectDetailActivity.describeDispatchPlanApprovalState("pending_user")); + assertEquals("等待主 Agent 处理", ProjectDetailActivity.describeDispatchPlanApprovalState("pending_agent")); + assertEquals("已确认,等待线程回流", ProjectDetailActivity.describeDispatchPlanApprovalState("approved")); + assertEquals("已拒绝", ProjectDetailActivity.describeDispatchPlanApprovalState("rejected")); + assertEquals("无需确认", ProjectDetailActivity.describeDispatchPlanApprovalState("not_required")); + } } diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 2782a28..71a0945 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -330,9 +330,11 @@ - `body` - `kind`: `text | voice_intent | image_intent | video_intent` - 当前行为: - - 普通项目直接写入消息账本 + - 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务 + - 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用 - `projectId=master-agent` 且 `kind=text` 时,会继续触发主 Agent 真实回复链路 - 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete` + - 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API` 账号,避免聊天直接只剩失败日志 - 如本机节点未接通,可切到 `OpenAI API` 容灾账号 - 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批 - 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案 @@ -400,6 +402,38 @@ - 用途:返回 AI 账号列表、当前主控身份和切换历史 +#### `POST /api/v1/accounts/onboard/openai-api` + +- 用途:通过 `OpenAI API Key` 在手机端登录 `OpenAI 平台账号` +- 输入: + - `label` + - `displayName` + - `accountIdentifier` + - `model` + - `apiKey` +- 当前行为: + - 先对候选 `API Key` 做真实 OpenAI 探针校验 + - 校验成功后创建或更新 `openai_api` 主账号 + - 立即设为当前主控 + - 返回 `activeIdentity` + - 若服务器当前无法访问 `api.openai.com`,会直接返回明确中文网络错误,而不是只返回 `fetch failed` + +#### `POST /api/v1/accounts/onboard/master-node` + +- 用途:显式绑定一台电脑上的 `Master Codex Node` +- 输入: + - `label` + - `displayName` + - `accountIdentifier` + - `nodeId` + - `nodeLabel` + - `model` +- 当前行为: + - 创建或更新 `master_codex_node` 主账号 + - 可直接切为当前主控 + - 不假装“手机里直接登录 GPT” + - 返回登录指引与当前校验结果 + #### `POST /api/v1/accounts` - 用途:新增 AI 账号 @@ -726,8 +760,10 @@ - `rawThreadReply` - 当前行为: - `completed` 时把真实主 Agent 回复写回 `master-agent` 项目消息账本 + - `taskType=conversation_reply` 时,会把目标 Codex 线程的原始回复写回普通单线程会话 - `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态 - `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态 + - 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户 - 当前保护:要求 `x-boss-device-token` 或匹配登录会话 ## 4. local-agent 接口 @@ -762,8 +798,10 @@ - local-agent 会周期性请求 `POST /api/v1/master-agent/tasks/claim` - 认领到任务后会执行本机 `codex exec` +- `conversation_reply / dispatch_execution` 当前会优先走 `codex exec resume `,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral` - 执行完成后会调用 `POST /api/v1/master-agent/tasks/[taskId]/complete` - 对群聊下发链路,认领到的 `dispatch_execution` 任务会带 `dispatchExecutionId / targetProjectId / targetThreadId` +- 对普通单线程聊天,认领到的 `conversation_reply` 任务会带 `targetProjectId / targetThreadId / targetCodexThreadRef` - local-agent 回写完成时会同时带上 `rawThreadReply`,服务端据此把线程原始结果和主 Agent 汇总回写到群聊 ## 5. 当前状态存储 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 995b0a7..283467b 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -13,6 +13,8 @@ - 主 Agent 项目详情:`http://127.0.0.1:3000/api/v1/projects/master-agent` - AI 账号摘要接口:`http://127.0.0.1:3000/api/v1/accounts` - AI 账号校验接口:`POST http://127.0.0.1:3000/api/v1/accounts/[accountId]/validate` +- AI 账号 OpenAI 登录接口:`POST http://127.0.0.1:3000/api/v1/accounts/onboard/openai-api` +- AI 账号 Master Node 绑定接口:`POST http://127.0.0.1:3000/api/v1/accounts/onboard/master-node` - 设备 Skill 同步接口:`http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` - 登录接口:`POST http://127.0.0.1:3000/api/auth/login` - 登录态接口:`GET http://127.0.0.1:3000/api/auth/session` @@ -100,16 +102,25 @@ cd /Users/kris/code/boss - 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent` 或 `审计对话` - 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目 - 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总 +- 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口 - 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口 +- 当前 `AI 账号` 页面已分成两条显式接入链:`登录 OpenAI 平台账号(API Key)` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控 +- 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,不再把失败日志直接原样回给用户 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口 - 当前 `dispatch_execution` 完成回写已补幂等,重复完成同一个线程执行单不会再重复向群聊追加线程原始回复和主 Agent 汇总 +- 当前原生 Android 已把三条聊天主链统一成等待真实回写:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 都会保持等待,直到收到实际回复或明确超时提示 - 当前设备导入 `review` 已补 owner/admin 鉴权,并会留下 `device_import_resolution` master task 轨迹;导入草稿在 `apply` 后再次 heartbeat 也不会从 `applied` 回退成 `resolved` - 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败” - 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新 - 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node` - `AI 账号` 页当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;`主 GPT` 需要先在绑定电脑上的 Codex / ChatGPT Plus 会话里登录,再回手机端点“测试连接 / 校验连接” +- `AI 账号` 页当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号` 和 `绑定电脑上的 Codex 节点` +- `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即设为当前主控 +- `绑定电脑上的 Codex 节点` 当前会保存 `master_codex_node` 主账号,并可立即切为当前主控;同时返回“登录发生在绑定设备上”的中文指引,不会再让用户误以为手机里能直接弹 ChatGPT OAuth +- 当前公网服务器对 `api.openai.com` 的直接出网仍未打通;远端 `curl https://api.openai.com/v1/models` 超时,Python `urllib` 返回 `Network is unreachable` +- 因此 `POST /api/v1/accounts/onboard/openai-api` 在公网环境下已经能返回明确中文网络错误,但在服务器出网恢复前,还不能完成真实 OpenAI 平台账号探针与调用 - `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示 - 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本` - 主 Agent 同步等待窗口当前为 55 秒;若本机 Codex 节点回复更慢,项目页仍会通过 SSE 在任务完成后自动刷新出真实回复 @@ -149,6 +160,8 @@ cd /Users/kris/code/boss - 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net` - `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理 - 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列 +- 当前 `local-agent` 对 `conversation_reply / dispatch_execution` 任务会优先使用 `codex exec resume `,只有缺失真实线程引用时才退回 `--ephemeral` +- 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员 - 当前设备导入决议已经会先落 `device_import_resolution` master task 再写回结果,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议 - 原生 Android 当前对 `master-agent` 聊天消息已单独放宽读超时到 `65s`;之前默认 `12s` 会把等待 `Master Codex Node / local-agent` 回写的长请求误判成“主 Agent 无响应” diff --git a/docs/superpowers/plans/2026-03-31-chat-path-completion.md b/docs/superpowers/plans/2026-03-31-chat-path-completion.md new file mode 100644 index 0000000..053ee3a --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-chat-path-completion.md @@ -0,0 +1,66 @@ +# 聊天主链补完 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 打通主 Agent 单聊、普通线程单聊和群聊推荐执行三条聊天链,并让 Android 端能稳定展示等待、确认、失败和结果回流。 + +**Architecture:** 后端继续沿用 `boss-state.json + master-agent task queue + local-agent` 路线,把普通线程单聊纳入 `conversation_reply` 任务执行链;群聊继续沿用 `dispatch_execution`。Android 端用轮询项目详情和待确认计划来收束聊天状态,而不是一次发送后只做单次刷新。 + +**Tech Stack:** Next.js App Router, TypeScript, file-backed state store, local-agent, Android AppCompat/Java, node:test, Gradle unit tests + +--- + +### Task 1: 后端补普通线程单聊执行链 + +**Files:** +- Modify: `/Users/kris/code/boss/src/app/api/v1/projects/[projectId]/messages/route.ts` +- Modify: `/Users/kris/code/boss/src/lib/boss-data.ts` +- Modify: `/Users/kris/code/boss/local-agent/server.mjs` +- Test: `/Users/kris/code/boss/tests/single-thread-message-execution.test.ts` + +- [ ] 写失败测试,覆盖“普通线程单聊发送后会排 `conversation_reply` 任务” +- [ ] 跑测试确认当前失败 +- [ ] 在消息路由和状态模型里补任务创建 +- [ ] 在 `local-agent` 完成回写时把线程原始回复写回单线程项目 +- [ ] 再跑测试确认通过 + +### Task 2: 后端补主 Agent/普通线程统一任务状态语义 + +**Files:** +- Modify: `/Users/kris/code/boss/src/app/api/v1/projects/[projectId]/messages/route.ts` +- Modify: `/Users/kris/code/boss/src/lib/boss-master-agent.ts` +- Test: `/Users/kris/code/boss/tests/project-message-task-status.test.ts` + +- [ ] 写失败测试,覆盖发送返回 `taskId/taskType/status` +- [ ] 跑测试确认失败 +- [ ] 在消息接口里把任务状态统一返回给客户端 +- [ ] 主 Agent 单聊保持现有执行逻辑,但返回值补清晰任务状态 +- [ ] 再跑测试确认通过 + +### Task 3: Android 补发送后等待/轮询/收束链 + +**Files:** +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java` +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityChatFlowTest.java` + +- [ ] 写失败测试,覆盖主 Agent / 普通线程 / 群聊三种发送后的状态流 +- [ ] 跑测试确认失败 +- [ ] 在 `ProjectDetailActivity` 增加轮询与等待态收束 +- [ ] 群聊确认后继续轮询直到看到线程结果或主 Agent 汇总 +- [ ] 再跑测试确认通过 + +### Task 4: 文档、验证、部署 + +**Files:** +- Modify: `/Users/kris/code/boss/README.md` +- Modify: `/Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md` +- Modify: `/Users/kris/code/boss/docs/architecture/api_and_service_inventory_cn.md` + +- [ ] 运行 `npm run lint` +- [ ] 运行 `npm run build` +- [ ] 运行新增 node:test 与 Android 单测 +- [ ] 验证 `curl -sS http://127.0.0.1:3000/api/health` +- [ ] 验证 `curl -sS http://127.0.0.1:4317/health` +- [ ] 部署到服务器并验证 `curl -sS https://boss.hyzq.net/api/health` +- [ ] 同步文档写明三条聊天链当前状态 diff --git a/docs/superpowers/specs/2026-03-31-chat-path-completion-design.md b/docs/superpowers/specs/2026-03-31-chat-path-completion-design.md new file mode 100644 index 0000000..e65daa7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-chat-path-completion-design.md @@ -0,0 +1,162 @@ +# 聊天主链补完设计 + +## 背景 + +当前 Boss 已经具备这些基础能力: + +- 主 Agent 会话、普通线程会话、群聊会话 +- `master-agent task queue -> local-agent -> codex exec -> complete` 的基础执行链 +- 群聊消息 -> 主 Agent 推荐下发 -> 用户确认 -> `dispatch_execution` -> 原始结果回群 的第一轮闭环 +- 设备导入、线程归档、原生 Android 聊天页和会话页 + +但“聊天通路”还没有真正打通,主要断点有三处: + +1. `主 Agent 单聊` + - 后端能接单并排队任务 + - Android 端没有等待态和轮询回收,用户会感知成“发了消息没反应” +2. `普通项目线程单聊` + - 当前只把用户消息写进账本 + - 没有创建真正的线程执行任务,也没有线程结果回写 +3. `群聊调度` + - 后端主链基本具备 + - Android 端仍缺“推荐计划待确认 / 执行中 / 回写后收束”的稳定体验 + +## 目标 + +1. 主 Agent 单聊在 APP 和 Web 中都能形成真实回复,不再只有消息入账 +2. 普通线程单聊也能进入 `local-agent -> codex exec -> complete` 执行链 +3. 群聊保持“主 Agent 推荐 -> 用户确认 -> 线程原始结果回群 -> 主 Agent 汇总”的现有结构,并补稳前台状态 +4. Android 聊天页发送后要有清晰的等待、超时、失败和完成回显 +5. 不改当前产品路线,不引入数据库或额外消息中间件 + +## 非目标 + +- 本轮不重做 UI 视觉 +- 本轮不改变设备导入或附件存储路线 +- 本轮不实现 ChatGPT OAuth +- 本轮不做新的原生真机视觉细抠 + +## 设计 + +### 1. 统一三条聊天链的后端执行模型 + +服务端把聊天请求统一收敛成两类: + +- `conversation_reply` + - 用于主 Agent 单聊和普通线程单聊 +- `dispatch_execution` + - 用于群聊推荐下发后,针对具体线程的执行单 + +#### 1.1 主 Agent 单聊 + +- `POST /api/v1/projects/master-agent/messages` +- 继续调用 `replyToMasterAgentUserMessage()` +- 仍然允许两种执行后端: + - `master_codex_node` + - `openai_api` +- 返回值继续允许“立即有内容”或“任务已排队” +- 但会明确返回 `taskStatus`,供 Android 端轮询 + +#### 1.2 普通线程单聊 + +- `POST /api/v1/projects/[threadProjectId]/messages` +- 如果目标项目: + - 不是 `master-agent` + - 不是群聊 + - 且有完整 `threadMeta` +- 则服务端在写入用户消息后,创建一个新的 `conversation_reply` 任务 +- 任务必须挂上: + - `projectId` + - `targetProjectId` + - `targetThreadId` + - `deviceId` + - `codexFolderRef` + - `codexThreadRef` + - `requestMessageId` +- `local-agent` 认领后,通过当前电脑已登录的 `codex` 会话执行,并把线程原始回复回写到这个单线程项目中 + +### 2. 单线程会话回写规则 + +普通线程单聊完成后: + +- 线程原始结果直接作为一条 `device` 或 `thread` 消息回到当前单线程项目 +- 不再额外套一个主 Agent 汇总层 +- 这样单线程会话保持“我和这个线程直接聊天”的心智 + +如果执行失败: + +- 在当前会话插入一条失败系统消息 +- 保留用户消息,不吞掉失败状态 + +### 3. 群聊保持现有编排结构,但补稳状态语义 + +群聊继续使用: + +- 用户消息入账 +- 主 Agent 推荐计划 `dispatchPlan` +- 用户确认 +- `dispatch_execution` +- 线程原始结果回群 +- 主 Agent 汇总 + +本轮只补强两个方面: + +- 前台能稳定恢复“当前还有待确认推荐” +- 执行中、失败、完成三种状态都能回到聊天页 + +### 4. Android 聊天页状态机 + +发送消息后不再只做“一次 reload”。 + +新增最小状态: + +- `pending_task` +- `pending_dispatch_plan` +- `awaiting_result` +- `failed` + +#### 4.1 主 Agent / 普通线程单聊 + +如果发送接口返回: + +- 已经带回真实回复 + - 直接刷新并结束等待态 +- 只返回 `taskId` + - Android 进入轮询 + - 轮询项目详情或任务状态,直到看到新回复 / 失败 / 超时 + +#### 4.2 群聊 + +如果返回 `dispatchPlan` + +- 立即显示“等待你确认主 Agent 推荐” +- 确认后显示“已下发,等待线程返回” +- 后续靠轮询项目详情把原始回复和主 Agent 汇总拉回来 + +### 5. API 返回语义补充 + +聊天发送接口要统一补足这些字段: + +- `masterReply` +- `dispatchPlan` +- `task` + - `taskId` + - `taskType` + - `status` +- `collaborationGate` + +即使没有立即回复,也要让客户端知道: + +- 是已经排队了 +- 还是需要确认 +- 还是确实失败了 + +### 6. 测试与验收 + +必须覆盖: + +1. 主 Agent 单聊:消息发送后产生真实回复或明确排队状态 +2. 普通线程单聊:消息发送后创建线程执行任务,并把线程回复写回项目 +3. 群聊:推荐计划、确认、结果回流保持可用 +4. Android:发送后不会只停在“消息已发送”,而是能收束成真实回复 / 推荐确认 / 失败提示 + diff --git a/local-agent/codex-task-runner.mjs b/local-agent/codex-task-runner.mjs new file mode 100644 index 0000000..8eb3b98 --- /dev/null +++ b/local-agent/codex-task-runner.mjs @@ -0,0 +1,51 @@ +export function buildCodexTaskExecution(config, task, outputFile) { + const targetThreadRef = + String(task?.targetCodexThreadRef || task?.targetThreadId || "").trim(); + const targetFolderRef = + String(task?.targetCodexFolderRef || config.masterAgentWorkdir || process.cwd()).trim() || + process.cwd(); + const prompt = String(task?.executionPrompt || ""); + + if ( + targetThreadRef && + (task?.taskType === "conversation_reply" || task?.taskType === "dispatch_execution") + ) { + const args = [ + "exec", + "resume", + "--skip-git-repo-check", + "-o", + outputFile, + ]; + if (config.masterAgentModel) { + args.push("-m", config.masterAgentModel); + } + args.push(targetThreadRef, prompt); + return { + mode: "resume", + cwd: targetFolderRef, + args, + }; + } + + const args = [ + "exec", + "--ephemeral", + "--skip-git-repo-check", + "-C", + config.masterAgentWorkdir || process.cwd(), + "-s", + config.masterAgentSandbox || "workspace-write", + "-o", + outputFile, + ]; + if (config.masterAgentModel) { + args.push("-m", config.masterAgentModel); + } + args.push(prompt); + return { + mode: "ephemeral", + cwd: config.masterAgentWorkdir || process.cwd(), + args, + }; +} diff --git a/local-agent/server.mjs b/local-agent/server.mjs index 2eece2f..e3ff263 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -6,6 +6,7 @@ import { access, readFile, readdir, rm } from "node:fs/promises"; import os from "node:os"; import { join, resolve } from "node:path"; import { discoverCodexProjectCandidates } from "./codex-session-discovery.mjs"; +import { buildCodexTaskExecution } from "./codex-task-runner.mjs"; async function loadConfig(configPath) { const raw = await readFile(resolve(configPath), "utf8"); @@ -298,25 +299,6 @@ async function completeMasterAgentTask(config, runtime, payload) { }; } -function buildCodexArgs(config, outputFile, prompt) { - const args = [ - "exec", - "--ephemeral", - "--skip-git-repo-check", - "-C", - config.masterAgentWorkdir || process.cwd(), - "-s", - config.masterAgentSandbox || "workspace-write", - "-o", - outputFile, - ]; - if (config.masterAgentModel) { - args.push("-m", config.masterAgentModel); - } - args.push(prompt); - return args; -} - function parseDispatchExecutionCompletion(rawOutput) { const trimmed = String(rawOutput || "").trim(); if (!trimmed) { @@ -363,9 +345,10 @@ async function runMasterAgentTask(config, runtime, task) { }; try { + const codexExecution = buildCodexTaskExecution(config, task, outputFile); await new Promise((resolveTask, rejectTask) => { - const child = spawn("codex", buildCodexArgs(config, outputFile, task.executionPrompt), { - cwd: config.masterAgentWorkdir || process.cwd(), + const child = spawn("codex", codexExecution.args, { + cwd: codexExecution.cwd, env: process.env, }); diff --git a/src/app/api/v1/accounts/onboard/master-node/route.ts b/src/app/api/v1/accounts/onboard/master-node/route.ts new file mode 100644 index 0000000..358e353 --- /dev/null +++ b/src/app/api/v1/accounts/onboard/master-node/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { readState, saveAiAccount } from "@/lib/boss-data"; +import { validateAiAccountConnection } from "@/lib/boss-master-agent"; + +function chooseMasterPrimaryAccountId(state: Awaited>) { + return ( + state.aiAccounts.find((item) => item.provider === "master_codex_node" && item.role === "primary") + ?.accountId || "master-codex-primary" + ); +} + +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().catch(() => ({}))) as { + label?: string; + displayName?: string; + accountIdentifier?: string; + nodeId?: string; + nodeLabel?: string; + model?: string; + setActive?: boolean; + }; + + if (!body.displayName?.trim()) { + return NextResponse.json({ ok: false, message: "显示名称不能为空。" }, { status: 400 }); + } + if (!body.nodeId?.trim()) { + return NextResponse.json({ ok: false, message: "请先填写节点 ID。" }, { status: 400 }); + } + + try { + const state = await readState(); + const accountId = chooseMasterPrimaryAccountId(state); + const account = await saveAiAccount({ + accountId, + label: body.label?.trim() || "主 GPT", + role: "primary", + provider: "master_codex_node", + displayName: body.displayName.trim(), + accountIdentifier: body.accountIdentifier?.trim() || undefined, + nodeId: body.nodeId.trim(), + nodeLabel: body.nodeLabel?.trim() || undefined, + model: body.model?.trim() || "gpt-5.4", + enabled: true, + setActive: body.setActive !== false, + loginStatusNote: "节点绑定信息已保存,请在绑定设备上的 Codex / ChatGPT Plus 会话里完成登录。", + }); + + const validation = await validateAiAccountConnection(account.accountId); + + return NextResponse.json({ + ok: true, + accountId: account.accountId, + account, + validation, + message: + body.setActive === false + ? "Master Codex Node 绑定信息已保存。" + : "Master Codex Node 已绑定,并设为当前主控。", + }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "MASTER_NODE_ONBOARD_FAILED" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/v1/accounts/onboard/openai-api/route.ts b/src/app/api/v1/accounts/onboard/openai-api/route.ts new file mode 100644 index 0000000..b782cc7 --- /dev/null +++ b/src/app/api/v1/accounts/onboard/openai-api/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { getMasterIdentitySummaryFromState, readState, saveAiAccount, updateAiAccountHealth } from "@/lib/boss-data"; +import { probeOpenAiApiAccount } from "@/lib/boss-master-agent"; + +function chooseOpenAiPrimaryAccountId(state: Awaited>) { + return ( + state.aiAccounts.find((item) => item.provider === "openai_api" && item.role === "primary")?.accountId || + "openai-api-primary" + ); +} + +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().catch(() => ({}))) as { + label?: string; + displayName?: string; + accountIdentifier?: string; + model?: string; + apiKey?: string; + }; + + if (!body.displayName?.trim()) { + return NextResponse.json({ ok: false, message: "显示名称不能为空。" }, { status: 400 }); + } + if (!body.apiKey?.trim()) { + return NextResponse.json({ ok: false, message: "请先填写 OpenAI API Key。" }, { status: 400 }); + } + + try { + const probe = await probeOpenAiApiAccount({ + apiKey: body.apiKey, + model: body.model, + }); + + const state = await readState(); + const accountId = chooseOpenAiPrimaryAccountId(state); + const account = await saveAiAccount({ + accountId, + label: body.label?.trim() || "主 GPT", + role: "primary", + provider: "openai_api", + displayName: body.displayName.trim(), + accountIdentifier: body.accountIdentifier?.trim() || undefined, + model: probe.model, + apiKey: body.apiKey.trim(), + enabled: true, + setActive: true, + loginStatusNote: "已在手机端登录 OpenAI 平台账号,可直接作为当前主控。", + }); + + await updateAiAccountHealth({ + accountId: account.accountId, + status: "ready", + lastError: undefined, + lastValidatedAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + }); + + const nextState = await readState(); + return NextResponse.json({ + ok: true, + accountId: account.accountId, + account, + activeIdentity: getMasterIdentitySummaryFromState(nextState), + requestId: probe.requestId, + message: "OpenAI 平台账号已登录,并设为当前主控。", + }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "OPENAI_ONBOARD_FAILED" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index ee265c2..0da0af0 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -3,9 +3,21 @@ import { requireRequestSession } from "@/lib/boss-auth"; import { appendProjectMessage, readState } from "@/lib/boss-data"; import { queueGroupDispatchPlan, + queueThreadConversationReplyTask, replyToMasterAgentUserMessage, } from "@/lib/boss-master-agent"; +function dispatchFailureNotice(error?: string) { + switch (error) { + case "GROUP_DISPATCH_TARGETS_REQUIRED": + return "当前群聊里还没有可下发的真实线程,请先在群资料里重新添加线程后再试。"; + case "DISPATCH_TARGET_PROJECT_NOT_FOUND": + return "当前群聊里有失效的线程引用,请重新整理群成员后再试。"; + default: + return error ? `主 Agent 暂时无法生成推荐线程:${error}` : "主 Agent 暂时无法生成推荐线程,请稍后重试。"; + } +} + export async function POST( request: NextRequest, context: { params: Promise<{ projectId: string }> }, @@ -37,8 +49,22 @@ export async function POST( } | null = null; let masterReply: - | { ok: boolean; reason?: string; message?: string; accountId?: string; requestId?: string } + | { + ok: boolean; + reason?: string; + message?: string; + accountId?: string; + requestId?: string; + taskId?: string; + } | undefined; + let task: + | { + taskId: string; + taskType: "conversation_reply"; + status: "queued" | "completed"; + } + | null = null; const state = await readState(); const project = state.projects.find((item) => item.id === projectId); @@ -58,13 +84,42 @@ export async function POST( }); dispatchRecommendation = recommendation; dispatchPlan = recommendation.dispatchPlan; + if (!recommendation.ok) { + await appendProjectMessage({ + projectId, + sender: "master", + senderLabel: "主 Agent", + body: dispatchFailureNotice(recommendation.error), + kind: "system_notice", + }); + } } catch (error) { dispatchRecommendation = { ok: false, status: "failed", error: error instanceof Error ? error.message : "GROUP_DISPATCH_PLAN_FAILED", }; + await appendProjectMessage({ + projectId, + sender: "master", + senderLabel: "主 Agent", + body: dispatchFailureNotice(dispatchRecommendation.error), + kind: "system_notice", + }); } + } 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", + }; } else { dispatchRecommendation = { ok: false, @@ -80,6 +135,13 @@ export async function POST( requestedByAccount: session.account, currentSessionExpiresAt: session.expiresAt, }); + if (masterReply?.ok && masterReply.taskId) { + task = { + taskId: masterReply.taskId, + taskType: "conversation_reply", + status: masterReply.requestId ? "completed" : "queued", + }; + } } const nextState = shouldCreateDispatchPlan ? await readState() : state; @@ -103,6 +165,7 @@ export async function POST( ok: true, message, masterReply, + task, dispatchPlan, dispatchRecommendation, collaborationGate, diff --git a/src/components/ai-accounts-client.tsx b/src/components/ai-accounts-client.tsx index 37ac055..12ae8fb 100644 --- a/src/components/ai-accounts-client.tsx +++ b/src/components/ai-accounts-client.tsx @@ -314,12 +314,13 @@ export function AiAccountsClient({ nodeId: draft.nodeId.trim(), nodeLabel: draft.nodeLabel.trim(), model: draft.model.trim() || "gpt-5.4", + setActive: true, }), }); const result = (await response.json()) as { ok: boolean; message?: string }; setBusyKey(null); if (result.ok) { - setMessage(result.message || "Master Codex Node 已绑定。"); + setMessage(result.message || "Master Codex Node 已绑定,并设为当前主控。"); setMasterNodeOnboardDraft(defaultMasterNodeOnboardDraft()); closeOnboarding(); router.refresh(); diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 9f3429e..fe4256c 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -327,6 +327,15 @@ export interface DeviceImportCandidate { suggestedImport: boolean; } +export function isDispatchableThreadProject(project: Project) { + return ( + project.id !== "master-agent" && + !project.isGroup && + Boolean(project.threadMeta.codexThreadRef?.trim()) && + project.deviceIds.length > 0 + ); +} + export interface DeviceImportDraft { draftId: string; deviceId: string; @@ -521,6 +530,8 @@ export interface MasterAgentTask { targetProjectId?: string; targetThreadId?: string; targetThreadDisplayName?: string; + targetCodexThreadRef?: string; + targetCodexFolderRef?: string; deviceImportDraftId?: string; status: MasterAgentTaskStatus; requestedAt: string; @@ -3812,7 +3823,10 @@ export async function saveAiAccount(payload: { const existing = payload.accountId ? state.aiAccounts.find((item) => item.accountId === payload.accountId) : null; - const accountId = existing?.accountId ?? `ai-${slugify(`${payload.label}-${payload.displayName}`)}`; + const accountId = + existing?.accountId ?? + payload.accountId?.trim() ?? + `ai-${slugify(`${payload.label}-${payload.displayName}`)}`; const next: AiAccount = normalizeAiAccount({ accountId, label: payload.label.trim() || aiRoleLabel(payload.role), @@ -3989,6 +4003,8 @@ export async function queueMasterAgentTask(payload: { targetProjectId?: string; targetThreadId?: string; targetThreadDisplayName?: string; + targetCodexThreadRef?: string; + targetCodexFolderRef?: string; }) { const task = await mutateState((state) => { const task: MasterAgentTask = { @@ -4014,6 +4030,8 @@ export async function queueMasterAgentTask(payload: { targetProjectId: payload.targetProjectId, targetThreadId: payload.targetThreadId, targetThreadDisplayName: payload.targetThreadDisplayName, + targetCodexThreadRef: payload.targetCodexThreadRef, + targetCodexFolderRef: payload.targetCodexFolderRef, status: "queued", requestedAt: nowIso(), }; @@ -4281,6 +4299,8 @@ function ensureDispatchExecutionTaskInState( existing.targetProjectId = existing.targetProjectId ?? execution.targetProjectId; existing.targetThreadId = existing.targetThreadId ?? execution.targetThreadId; existing.targetThreadDisplayName = existing.targetThreadDisplayName ?? target.threadDisplayName; + existing.targetCodexThreadRef = existing.targetCodexThreadRef ?? target.codexThreadRef; + existing.targetCodexFolderRef = existing.targetCodexFolderRef ?? target.codexFolderRef; existing.executionPrompt = existing.executionPrompt || buildDispatchExecutionPrompt({ @@ -4311,6 +4331,8 @@ function ensureDispatchExecutionTaskInState( targetProjectId: execution.targetProjectId, targetThreadId: execution.targetThreadId, targetThreadDisplayName: target.threadDisplayName, + targetCodexThreadRef: target.codexThreadRef, + targetCodexFolderRef: target.codexFolderRef, status: "queued", requestedAt: nowIso(), }; @@ -4790,17 +4812,48 @@ export async function completeMasterAgentTask(payload: { masterSummary: payload.replyBody?.trim(), }); } else if (!attachmentProjectId && payload.status === "completed" && task.replyBody) { - pushProjectLedgerMessage(state, task.projectId, { - sender: "master", - senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent", - body: task.replyBody, - kind: "text", - }); + const isThreadConversationReply = + task.taskType === "conversation_reply" && + task.projectId !== "master-agent" && + Boolean(task.targetProjectId && task.targetThreadId); + if (isThreadConversationReply) { + const threadProject = state.projects.find( + (item) => item.id === (task.targetProjectId ?? task.projectId), + ); + const device = state.devices.find((item) => item.id === payload.deviceId); + pushProjectLedgerMessage(state, threadProject?.id ?? task.projectId, { + sender: "device", + senderLabel: + task.targetThreadDisplayName?.trim() || + threadProject?.threadMeta.threadDisplayName || + device?.name || + "线程", + body: task.replyBody, + kind: "text", + }); + } else { + pushProjectLedgerMessage(state, task.projectId, { + sender: "master", + senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent", + body: task.replyBody, + kind: "text", + }); + } } else if (!attachmentProjectId && payload.status === "failed") { + const isThreadConversationReply = + task.taskType === "conversation_reply" && + task.projectId !== "master-agent" && + Boolean(task.targetProjectId && task.targetThreadId); pushProjectLedgerMessage(state, task.projectId, { sender: "ops", - senderLabel: task.accountLabel ? `主 Agent Relay · ${task.accountLabel}` : "主 Agent Relay", - body: `Master Codex Node 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`, + senderLabel: isThreadConversationReply + ? "线程执行失败" + : task.accountLabel + ? `主 Agent Relay · ${task.accountLabel}` + : "主 Agent Relay", + body: isThreadConversationReply + ? `${task.targetThreadDisplayName ?? "当前线程"} 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}` + : `Master Codex Node 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`, kind: "text", }); } @@ -6233,6 +6286,9 @@ function createGroupChatFromProjectIds( if (!memberProject) { throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND"); } + if (!isDispatchableThreadProject(memberProject)) { + throw new Error("GROUP_CHAT_MEMBER_NOT_THREAD"); + } memberProjects.push(memberProject); } if (memberProjects.length < 2) { diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index d192c74..0b294e5 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { AUTH_SESSION_TTL_MS, + aiRoleLabel, aiProviderLabel, appendProjectMessage, completeMasterAgentTask, @@ -13,10 +14,11 @@ import { previewDeviceImportResolution, queueMasterAgentTask, readState, + isDispatchableThreadProject, updateAttachmentAnalysisResult, updateAiAccountHealth, } from "@/lib/boss-data"; -import type { DispatchPlanTarget, GroupConversationMember, Project } from "@/lib/boss-data"; +import type { DispatchPlanTarget, Project } from "@/lib/boss-data"; import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments"; import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss"; import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file"; @@ -32,6 +34,21 @@ function buildMasterAgentInstructions() { ].join("\n"); } +function buildThreadConversationReplyPrompt(project: Project, requestText: string) { + return [ + "你正在代表某个 Codex 线程回复 Boss 控制台里的单线程会话。", + "你不是主 Agent,不要使用“主 Agent”口吻,不要写总结,不要解释调度过程。", + "请直接像该线程本人一样,用中文回复用户当前这条消息。", + "如果信息不足,要明确说缺什么;不要假装已经执行过设备操作。", + "输出要求:只输出线程要回给用户的正文,不要输出 JSON、代码块或额外前缀。", + `threadProjectId: ${project.id}`, + `threadTitle: ${project.threadMeta.threadDisplayName}`, + `folderName: ${project.threadMeta.folderName}`, + `deviceIds: ${project.deviceIds.join(",")}`, + `requestText: ${requestText}`, + ].join("\n"); +} + function buildRuntimeDigest( state: Awaited>, requestText: string, @@ -133,11 +150,93 @@ function extractResponseText(payload: unknown): string { function normalizeOpenAiError(message: string) { const trimmed = message.trim(); + const lowered = trimmed.toLowerCase(); + if (lowered.includes("network is unreachable") || lowered.includes("enetunreach")) { + return "服务器当前无法访问 api.openai.com,请先恢复服务器出网,或先切回 Master Codex Node。"; + } + if (lowered.includes("fetch failed") || lowered.includes("connect timeout") || lowered.includes("timed out")) { + return "服务器当前无法连接 OpenAI API,请检查出网、代理或防火墙配置。"; + } if (!trimmed) return "主 Agent 当前调用模型失败。"; if (trimmed.length <= 240) return trimmed; return `${trimmed.slice(0, 237)}...`; } +function normalizeOpenAiFetchFailure(error: unknown) { + if (error instanceof Error) { + const causeCode = + typeof (error as Error & { cause?: { code?: string } }).cause?.code === "string" + ? (error as Error & { cause?: { code?: string } }).cause?.code + : ""; + const causeMessage = + (error as Error & { cause?: { message?: string } }).cause?.message?.trim() || ""; + return normalizeOpenAiError([error.message, causeCode, causeMessage].filter(Boolean).join(" ")); + } + return normalizeOpenAiError(String(error)); +} + +function fallbackAiRolePriority(role: "primary" | "backup" | "api_fallback") { + switch (role) { + case "primary": + return 0; + case "backup": + return 1; + case "api_fallback": + return 2; + default: + return 9; + } +} + +async function findFallbackOpenAiAccount(excludedAccountId?: string) { + const state = await readState(); + return [...state.aiAccounts] + .filter( + (account) => + account.accountId !== excludedAccountId && + account.enabled && + account.provider === "openai_api" && + Boolean(account.apiKey?.trim()), + ) + .sort((left, right) => { + const roleDelta = fallbackAiRolePriority(left.role) - fallbackAiRolePriority(right.role); + if (roleDelta !== 0) return roleDelta; + return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); + })[0]; +} + +async function replyViaOpenAiAccount(params: { + account: Awaited>; + requestText: string; + currentSessionExpiresAt?: string; + senderLabel: string; +}) { + if (!params.account?.apiKey?.trim()) { + throw new Error("OPENAI_ACCOUNT_NOT_CONFIGURED"); + } + + const generated = await generateOpenAiReply({ + apiKey: params.account.apiKey, + model: params.account.model || "gpt-5.4", + requestText: params.requestText, + currentSessionExpiresAt: params.currentSessionExpiresAt, + }); + + await appendMasterAgentSystemReply(generated.content, params.senderLabel); + await updateAiAccountHealth({ + accountId: params.account.accountId, + status: "ready", + lastValidatedAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + }); + + return { + ok: true as const, + accountId: params.account.accountId, + requestId: generated.requestId, + }; +} + async function generateOpenAiReply(params: { apiKey: string; model: string; @@ -145,20 +244,25 @@ async function generateOpenAiReply(params: { currentSessionExpiresAt?: string; }) { const state = await readState(); - const response = await fetch("https://api.openai.com/v1/responses", { - method: "POST", - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: params.model, - reasoning: { effort: "medium" }, - instructions: buildMasterAgentInstructions(), - input: buildRuntimeDigest(state, params.requestText, params.currentSessionExpiresAt), - }), - signal: AbortSignal.timeout(45_000), - }); + let response: Response; + try { + response = await fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: params.model, + reasoning: { effort: "medium" }, + instructions: buildMasterAgentInstructions(), + input: buildRuntimeDigest(state, params.requestText, params.currentSessionExpiresAt), + }), + signal: AbortSignal.timeout(45_000), + }); + } catch (error) { + throw new Error(normalizeOpenAiFetchFailure(error)); + } const requestId = response.headers.get("x-request-id") ?? undefined; const payload = (await response.json().catch(() => null)) as @@ -192,6 +296,62 @@ async function generateOpenAiReply(params: { }; } +export async function probeOpenAiApiAccount(params: { + apiKey: string; + model?: string; +}) { + const apiKey = params.apiKey.trim(); + if (!apiKey) { + throw new Error("当前账号还没有可用的 OpenAI API Key。"); + } + + const model = params.model?.trim() || "gpt-5.4"; + let response: Response; + try { + response = await fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + reasoning: { effort: "low" }, + instructions: "你正在执行 OpenAI API 连接自检。请只回复“连接正常”。", + input: "请只回复“连接正常”。", + }), + signal: AbortSignal.timeout(15_000), + }); + } catch (error) { + throw new Error(normalizeOpenAiFetchFailure(error)); + } + + const requestId = response.headers.get("x-request-id") ?? undefined; + const payload = (await response.json().catch(() => null)) as + | { error?: { message?: string } } + | null; + + if (!response.ok) { + const apiError = + payload && typeof payload === "object" && "error" in payload + ? payload.error?.message + : undefined; + throw new Error( + normalizeOpenAiError( + `${apiError ?? `OpenAI API ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`, + ), + ); + } + + const content = extractResponseText(payload) || "连接正常。"; + return { + ok: true as const, + message: content, + requestId, + model, + }; +} + async function appendMasterAgentSystemReply(body: string, senderLabel = "主 Agent") { return appendProjectMessage({ projectId: "master-agent", @@ -229,15 +389,11 @@ function summarizeDispatchRequest(requestText: string) { } function collectGroupDispatchTargets( + state: Awaited>, project: Project, requestText: string, ): DispatchPlanTarget[] { - const members: Array< - Pick< - GroupConversationMember, - "deviceId" | "projectId" | "threadId" | "threadDisplayName" | "folderName" - > - > = + const members = project.groupMembers.length > 0 ? project.groupMembers : project.deviceIds.map((deviceId) => ({ @@ -249,20 +405,27 @@ function collectGroupDispatchTargets( })); return members - .map((member) => ({ - deviceId: member.deviceId, - projectId: member.projectId, - threadId: member.threadId, - threadDisplayName: member.threadDisplayName, - folderName: member.folderName, + .map((member) => { + const candidate = state.projects.find((projectCandidate) => projectCandidate.id === member.projectId); + if (!candidate) { + throw new Error("DISPATCH_TARGET_PROJECT_NOT_FOUND"); + } + return candidate; + }) + .filter((candidate) => isDispatchableThreadProject(candidate)) + .map((candidate) => ({ + deviceId: candidate.deviceIds[0] ?? candidate.id, + projectId: candidate.id, + threadId: candidate.threadMeta.threadId, + threadDisplayName: candidate.threadMeta.threadDisplayName, + folderName: candidate.threadMeta.folderName, + codexFolderRef: candidate.threadMeta.codexFolderRef, + codexThreadRef: candidate.threadMeta.codexThreadRef, reason: `群聊消息“${summarizeDispatchRequest(requestText)}”需要该线程补充状态或执行建议。`, })) .filter((target, index, array) => { const signature = `${target.projectId}::${target.deviceId}::${target.threadId}`; - return ( - array.findIndex((item) => `${item.projectId}::${item.deviceId}::${item.threadId}` === signature) === - index - ); + return array.findIndex((item) => `${item.projectId}::${item.deviceId}::${item.threadId}` === signature) === index; }); } @@ -336,7 +499,7 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise item.id === params.projectId); + if (!project) { + throw new Error("PROJECT_NOT_FOUND"); + } + if (project.isGroup) { + throw new Error("PROJECT_NOT_SINGLE_THREAD"); + } + if (project.id === "master-agent") { + throw new Error("PROJECT_NOT_THREAD_CONVERSATION"); + } + + const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio"; + return queueMasterAgentTask({ + projectId: project.id, + taskType: "conversation_reply", + requestMessageId: params.requestMessageId, + requestText: params.requestText, + executionPrompt: buildThreadConversationReplyPrompt(project, params.requestText), + requestedBy: params.requestedBy, + requestedByAccount: params.requestedByAccount, + deviceId, + targetProjectId: project.id, + targetThreadId: project.threadMeta.threadId, + targetThreadDisplayName: project.threadMeta.threadDisplayName, + targetCodexThreadRef: project.threadMeta.codexThreadRef, + targetCodexFolderRef: project.threadMeta.codexFolderRef, + }); +} + function buildDeviceImportResolutionPrompt(params: { deviceName: string; deviceId: string; @@ -813,10 +1013,9 @@ export async function validateAiAccountConnection(accountId: string) { }; } - const generated = await generateOpenAiReply({ + const generated = await probeOpenAiApiAccount({ apiKey: account.apiKey, model: account.model || "gpt-5.4", - requestText: "请只回复“连接正常”。", }); await updateAiAccountHealth({ @@ -829,7 +1028,7 @@ export async function validateAiAccountConnection(accountId: string) { return { ok: true as const, status: "ready", - message: generated.content, + message: generated.message, requestId: generated.requestId, }; } @@ -867,6 +1066,19 @@ export async function replyToMasterAgentUserMessage(params: { lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE", lastValidatedAt: new Date().toISOString(), }); + const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId); + if (fallbackAccount) { + try { + return await replyViaOpenAiAccount({ + account: fallbackAccount, + requestText: params.requestText, + currentSessionExpiresAt: params.currentSessionExpiresAt, + senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`, + }); + } catch { + // Fall through to the original offline guidance when the fallback API account cannot respond. + } + } await appendMasterAgentSystemReply( `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`, `主 Agent · ${runtime.summary.roleLabel}`, @@ -898,6 +1110,19 @@ export async function replyToMasterAgentUserMessage(params: { }; } if (completedTask?.status === "failed") { + const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId); + if (fallbackAccount) { + try { + return await replyViaOpenAiAccount({ + account: fallbackAccount, + requestText: params.requestText, + currentSessionExpiresAt: params.currentSessionExpiresAt, + senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`, + }); + } catch { + // Preserve the original execution failure below if the fallback account also fails. + } + } return { ok: false as const, reason: "MASTER_NODE_EXEC_FAILED", diff --git a/tests/ai-account-onboarding.test.ts b/tests/ai-account-onboarding.test.ts new file mode 100644 index 0000000..afd434c --- /dev/null +++ b/tests/ai-account-onboarding.test.ts @@ -0,0 +1,190 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let openAiOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/openai-api/route"))["POST"]; +let masterNodeOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/master-node/route"))["POST"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let AUTH_SESSION_COOKIE: string; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-ai-onboard-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [openAiModule, masterNodeModule, data, auth] = await Promise.all([ + import("../src/app/api/v1/accounts/onboard/openai-api/route.ts"), + import("../src/app/api/v1/accounts/onboard/master-node/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + openAiOnboardRoute = openAiModule.POST; + masterNodeOnboardRoute = masterNodeModule.POST; + createAuthSession = data.createAuthSession; + readState = data.readState; + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +async function createAuthedJsonRequest(url: string, body: Record) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(url, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify(body), + }); +} + +test("POST /api/v1/accounts/onboard/openai-api creates a primary openai account and activates it", async () => { + await setup(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + if (typeof input === "string" && input === "https://api.openai.com/v1/responses") { + assert.equal(init?.method, "POST"); + return new Response(JSON.stringify({ output_text: "连接正常" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-request-id": "req-onboard-openai", + }, + }); + } + throw new Error(`unexpected fetch: ${String(input)}`); + }) as typeof fetch; + + try { + const response = await openAiOnboardRoute( + await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/openai-api", { + label: "主 GPT", + displayName: "OpenAI 平台账号", + accountIdentifier: "sk-proj-demo", + model: "gpt-5.4", + apiKey: "sk-live-demo-123456", + }), + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + accountId: string; + message: string; + activeIdentity: { accountId: string; provider: string; displayName: string }; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.accountId, "openai-api-primary"); + assert.equal(payload.activeIdentity.accountId, "openai-api-primary"); + assert.equal(payload.activeIdentity.provider, "openai_api"); + assert.match(payload.message, /已登录/); + assert.match(payload.message, /当前主控/); + + const state = await readState(); + const account = state.aiAccounts.find((item) => item.accountId === "openai-api-primary"); + assert.ok(account, "expected openai primary account to be created"); + assert.equal(account?.provider, "openai_api"); + assert.equal(account?.role, "primary"); + assert.equal(account?.isActive, true); + assert.equal(account?.status, "ready"); + assert.equal(account?.displayName, "OpenAI 平台账号"); + assert.equal(account?.apiKey, "sk-live-demo-123456"); + assert.match(account?.apiKeyMasked ?? "", /\.\.\./); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("POST /api/v1/accounts/onboard/master-node upserts a master node account and returns bound-device guidance", async () => { + await setup(); + + const response = await masterNodeOnboardRoute( + await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/master-node", { + label: "主 GPT", + displayName: "Mac 上的 Master Codex Node", + accountIdentifier: "17600003315", + nodeId: "mac-studio", + nodeLabel: "Mac Studio", + model: "gpt-5.4", + setActive: true, + }), + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + accountId: string; + message: string; + validation: { status: string; message: string }; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.accountId, "master-codex-primary"); + assert.match(payload.message, /当前主控|已绑定/); + assert.equal(payload.validation.status, "ready"); + assert.match(payload.validation.message, /不在手机里直接登录/); + assert.match(payload.validation.message, /Mac Studio|绑定设备/); + + const state = await readState(); + const account = state.aiAccounts.find((item) => item.accountId === "master-codex-primary"); + assert.ok(account, "expected master node primary account"); + assert.equal(account?.provider, "master_codex_node"); + assert.equal(account?.displayName, "Mac 上的 Master Codex Node"); + assert.equal(account?.nodeId, "mac-studio"); + assert.equal(account?.nodeLabel, "Mac Studio"); + assert.equal(account?.isActive, true); +}); + +test("POST /api/v1/accounts/onboard/openai-api returns a clear network guidance when OpenAI is unreachable", async () => { + await setup(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + const error = new TypeError("fetch failed"); + (error as TypeError & { cause?: { code?: string; message?: string } }).cause = { + code: "ENETUNREACH", + message: "connect ENETUNREACH api.openai.com", + }; + throw error; + }) as typeof fetch; + + try { + const response = await openAiOnboardRoute( + await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/openai-api", { + label: "主 GPT", + displayName: "OpenAI 平台账号", + accountIdentifier: "sk-proj-demo", + model: "gpt-5.4", + apiKey: "sk-live-demo-123456", + }), + ); + + assert.equal(response.status, 400); + const payload = (await response.json()) as { ok: boolean; message: string }; + assert.equal(payload.ok, false); + assert.match(payload.message, /无法访问 api\.openai\.com|无法连接 OpenAI API/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/group-message-dispatch-plan.test.ts b/tests/group-message-dispatch-plan.test.ts index 9b7b086..60648dd 100644 --- a/tests/group-message-dispatch-plan.test.ts +++ b/tests/group-message-dispatch-plan.test.ts @@ -8,7 +8,7 @@ import { NextRequest } from "next/server"; let runtimeRoot = ""; let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"]; let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; -let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"]; +let createIndependentGroupChat: (typeof import("../src/lib/boss-data"))["createIndependentGroupChat"]; let readState: (typeof import("../src/lib/boss-data"))["readState"]; let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; let AUTH_SESSION_COOKIE: string; @@ -30,7 +30,7 @@ async function setup() { POST = routePost; createAuthSession = data.createAuthSession; - createProjectGroupChat = data.createProjectGroupChat; + createIndependentGroupChat = data.createIndependentGroupChat; readState = data.readState; writeState = data.writeState; AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; @@ -62,38 +62,67 @@ async function createAuthedRequest(projectId: string, body: { body: string; kind async function ensureTwoSingleThreadProjects() { const state = await readState(); - const singles = state.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); - if (singles.length >= 2) { - return singles; - } + const seed = state.projects.find((project) => project.id !== "master-agent" && !project.isGroup); + assert.ok(seed, "expected at least one seeded single-thread project"); - assert.ok(singles[0], "expected at least one seeded single-thread project"); - const seed = singles[0]; - const clonedProject = { + const primaryProject = { ...seed, - id: "boss-console-clone", + id: "dispatch-thread-a", + name: "Boss 移动控制台主线程", + deviceIds: ["mac-studio"], + updatedAt: "2026-03-30T10:00:00+08:00", + lastMessageAt: "2026-03-30T10:00:00+08:00", + preview: "主线程正在等待汇总今天的联调阻塞点。", + threadMeta: { + ...seed.threadMeta, + projectId: "dispatch-thread-a", + threadId: "thread-dispatch-a", + threadDisplayName: "北区试产线回归", + folderName: "阻塞梳理", + updatedAt: "2026-03-30T10:00:00+08:00", + codexThreadRef: "thread-dispatch-a", + codexFolderRef: "/Users/kris/code/boss", + }, + groupMembers: [], + messages: [ + { + id: "msg-dispatch-a", + sender: "device" as const, + senderLabel: "Mac Studio / Codex", + body: "主线程还在等待主 Agent 汇总阻塞点。", + sentAt: "2026-03-30T10:00:00+08:00", + kind: "text" as const, + }, + ], + goals: [], + versions: [], + }; + + const secondaryProject = { + ...seed, + id: "dispatch-thread-b", name: "Boss 移动控制台副线程", - deviceIds: ["win-gpu-01"], + deviceIds: ["mac-studio"], updatedAt: "2026-03-30T10:00:00+08:00", lastMessageAt: "2026-03-30T10:00:00+08:00", preview: "副线程等待主 Agent 汇总阻塞点。", threadMeta: { ...seed.threadMeta, - projectId: "boss-console-clone", - threadId: "thread-boss-ui-clone", + projectId: "dispatch-thread-b", + threadId: "thread-dispatch-b", threadDisplayName: "南区试产线回归", folderName: "阻塞梳理", updatedAt: "2026-03-30T10:00:00+08:00", - codexThreadRef: "thread-boss-ui-clone", - codexFolderRef: "boss-console-clone", + codexThreadRef: "thread-dispatch-b", + codexFolderRef: "/Users/kris/code/boss", }, groupMembers: [], messages: [ { - id: "msg-boss-console-clone", + id: "msg-dispatch-b", sender: "device" as const, - senderLabel: "Win GPU / Codex", - body: "这里还在等待视觉链路复核。", + senderLabel: "Mac Studio / Codex", + body: "副线程还在等待视觉链路复核。", sentAt: "2026-03-30T10:00:00+08:00", kind: "text" as const, }, @@ -104,11 +133,12 @@ async function ensureTwoSingleThreadProjects() { await writeState({ ...state, - projects: [...state.projects, clonedProject], + projects: state.projects + .filter((project) => !["dispatch-thread-a", "dispatch-thread-b"].includes(project.id)) + .concat(primaryProject, secondaryProject), }); - const nextState = await readState(); - return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); + return [primaryProject, secondaryProject]; } test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for group text messages", async () => { @@ -116,9 +146,8 @@ test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for gro const memberProjects = await ensureTwoSingleThreadProjects(); assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects"); - const groupProject = await createProjectGroupChat({ - sourceProjectId: memberProjects[0].id, - memberProjectIds: [memberProjects[1].id], + const groupProject = await createIndependentGroupChat({ + memberProjectIds: [memberProjects[0].id, memberProjects[1].id], createdBy: "17600003315", }); @@ -195,9 +224,8 @@ test("POST /api/v1/projects/[projectId]/messages marks approval_required groups const memberProjects = await ensureTwoSingleThreadProjects(); assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects"); - const groupProject = await createProjectGroupChat({ - sourceProjectId: memberProjects[0].id, - memberProjectIds: [memberProjects[1].id], + const groupProject = await createIndependentGroupChat({ + memberProjectIds: [memberProjects[0].id, memberProjects[1].id], createdBy: "17600003315", }); @@ -249,9 +277,8 @@ test("POST /api/v1/projects/[projectId]/messages keeps message success when grou const memberProjects = await ensureTwoSingleThreadProjects(); assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects"); - const groupProject = await createProjectGroupChat({ - sourceProjectId: memberProjects[0].id, - memberProjectIds: [memberProjects[1].id], + const groupProject = await createIndependentGroupChat({ + memberProjectIds: [memberProjects[0].id, memberProjects[1].id], createdBy: "17600003315", }); @@ -306,6 +333,16 @@ test("POST /api/v1/projects/[projectId]/messages keeps message success when grou .find((project) => project.id === groupProject.id) ?.messages.find((message) => message.id === payload.message.id); assert.ok(savedMessage, "expected user message to remain persisted even when dispatch recommendation fails"); + const persistedMessages = + nextState.projects.find((project) => project.id === groupProject.id)?.messages ?? []; + const savedMessageIndex = persistedMessages.findIndex((message) => message.id === payload.message.id); + assert.notEqual(savedMessageIndex, -1, "expected the user message to remain in the project timeline"); + assert.ok( + persistedMessages + .slice(savedMessageIndex + 1) + .some((message) => message.sender === "master"), + "expected a user-visible failure notice to be appended for dispatch errors", + ); const failedTask = nextState.masterAgentTasks.find( (task) => @@ -316,3 +353,76 @@ test("POST /api/v1/projects/[projectId]/messages keeps message success when grou assert.ok(failedTask, "expected failed dispatch recommendation task to be recorded"); assert.equal(failedTask?.status, "failed"); }); + +test("POST /api/v1/projects/[projectId]/messages excludes master-agent from group dispatch targets", async () => { + await setup(); + const memberProjects = await ensureTwoSingleThreadProjects(); + assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects"); + + const groupProject = await createIndependentGroupChat({ + memberProjectIds: [memberProjects[0].id, memberProjects[1].id], + createdBy: "17600003315", + }); + + const state = await readState(); + await writeState({ + ...state, + projects: state.projects.map((project) => + project.id === groupProject.id + ? { + ...project, + groupMembers: [ + ...project.groupMembers, + { + projectId: "master-agent", + deviceId: "mac-studio", + threadId: "thread-master-agent", + threadDisplayName: "主 Agent", + folderName: "主控线程", + }, + ], + } + : project, + ), + }); + + const response = await POST(await createAuthedRequest(groupProject.id, { body: "请继续同步联调进展" }), { + params: Promise.resolve({ projectId: groupProject.id }), + }); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + dispatchPlan: null | { + targets: Array<{ projectId: string }>; + }; + }; + + assert.ok(payload.dispatchPlan, "expected dispatch plan"); + assert.deepEqual( + payload.dispatchPlan?.targets.map((target) => target.projectId), + groupProject.groupMembers.map((member) => member.projectId), + ); + assert.equal( + payload.dispatchPlan?.targets.some((target) => target.projectId === "master-agent"), + false, + "master-agent should never appear as a dispatch target", + ); +}); + +test("createIndependentGroupChat rejects non-thread members like master-agent", async () => { + await setup(); + const state = await readState(); + const realThread = state.projects.find( + (project) => project.id !== "master-agent" && !project.isGroup && Boolean(project.threadMeta.codexThreadRef), + ); + assert.ok(realThread, "expected a real thread-backed project"); + + await assert.rejects( + () => + createIndependentGroupChat({ + memberProjectIds: ["master-agent", realThread.id], + createdBy: "17600003315", + }), + /GROUP_CHAT_MEMBER_NOT_THREAD/, + ); +}); diff --git a/tests/local-agent-codex-task-runner.test.mjs b/tests/local-agent-codex-task-runner.test.mjs new file mode 100644 index 0000000..ae0e556 --- /dev/null +++ b/tests/local-agent-codex-task-runner.test.mjs @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildCodexTaskExecution } from "../local-agent/codex-task-runner.mjs"; + +test("conversation reply resumes the real Codex thread when thread ref is available", () => { + const execution = buildCodexTaskExecution( + { + masterAgentWorkdir: "/Users/kris/code/boss", + masterAgentSandbox: "workspace-write", + masterAgentModel: "gpt-5.4", + }, + { + taskType: "conversation_reply", + executionPrompt: "请回复用户", + targetCodexThreadRef: "019d-thread-real", + targetCodexFolderRef: "/Users/kris/code/meiyesaas", + }, + "/tmp/reply.txt", + ); + + assert.equal(execution.mode, "resume"); + assert.equal(execution.cwd, "/Users/kris/code/meiyesaas"); + assert.deepEqual(execution.args, [ + "exec", + "resume", + "--skip-git-repo-check", + "-o", + "/tmp/reply.txt", + "-m", + "gpt-5.4", + "019d-thread-real", + "请回复用户", + ]); +}); + +test("dispatch execution falls back to targetThreadId when codex thread ref is missing", () => { + const execution = buildCodexTaskExecution( + { + masterAgentWorkdir: "/Users/kris/code/boss", + masterAgentSandbox: "workspace-write", + }, + { + taskType: "dispatch_execution", + executionPrompt: "请执行群聊任务", + targetThreadId: "019d-thread-fallback", + }, + "/tmp/reply.txt", + ); + + assert.equal(execution.mode, "resume"); + assert.deepEqual(execution.args, [ + "exec", + "resume", + "--skip-git-repo-check", + "-o", + "/tmp/reply.txt", + "019d-thread-fallback", + "请执行群聊任务", + ]); +}); + +test("master agent reply without target thread stays on ephemeral exec", () => { + const execution = buildCodexTaskExecution( + { + masterAgentWorkdir: "/Users/kris/code/boss", + masterAgentSandbox: "workspace-write", + masterAgentModel: "gpt-5.4", + }, + { + taskType: "conversation_reply", + executionPrompt: "你是主 Agent", + }, + "/tmp/master.txt", + ); + + assert.equal(execution.mode, "ephemeral"); + assert.equal(execution.cwd, "/Users/kris/code/boss"); + assert.deepEqual(execution.args, [ + "exec", + "--ephemeral", + "--skip-git-repo-check", + "-C", + "/Users/kris/code/boss", + "-s", + "workspace-write", + "-o", + "/tmp/master.txt", + "-m", + "gpt-5.4", + "你是主 Agent", + ]); +}); diff --git a/tests/master-agent-openai-fallback.test.ts b/tests/master-agent-openai-fallback.test.ts new file mode 100644 index 0000000..689631f --- /dev/null +++ b/tests/master-agent-openai-fallback.test.ts @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; + +let runtimeRoot = ""; +let replyToMasterAgentUserMessage: (typeof import("../src/lib/boss-master-agent"))["replyToMasterAgentUserMessage"]; +let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-fallback-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [masterAgent, data] = await Promise.all([ + import("../src/lib/boss-master-agent.ts"), + import("../src/lib/boss-data.ts"), + ]); + + replyToMasterAgentUserMessage = masterAgent.replyToMasterAgentUserMessage; + saveAiAccount = data.saveAiAccount; + readState = data.readState; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account when the master node is offline", async () => { + await setup(); + + await saveAiAccount({ + accountId: "master-codex-primary", + label: "主 GPT", + role: "primary", + provider: "master_codex_node", + displayName: "Mac 上的 Master Codex Node", + nodeId: "offline-node", + nodeLabel: "离线节点", + model: "gpt-5.4", + enabled: true, + setActive: true, + loginStatusNote: "通过绑定的 Master Codex Node 对话。", + }); + + await saveAiAccount({ + accountId: "openai-backup", + label: "备用 GPT", + role: "backup", + provider: "openai_api", + displayName: "OpenAI API 备用账号", + accountIdentifier: "sk-demo", + model: "gpt-5.4", + apiKey: "sk-live-demo-123456", + enabled: true, + setActive: false, + loginStatusNote: "备用 API 账号。", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + if (typeof input === "string" && input === "https://api.openai.com/v1/responses") { + return new Response(JSON.stringify({ output_text: "主Agent链路正常。" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-request-id": "req-master-fallback", + }, + }); + } + throw new Error(`unexpected fetch: ${String(input)}`); + }) as typeof fetch; + + try { + const result = await replyToMasterAgentUserMessage({ + requestMessageId: "msg-master-fallback", + requestText: "请只回复:主Agent链路正常。", + requestedBy: "Boss 超级管理员", + requestedByAccount: "17600003315", + }); + + assert.equal(result.ok, true); + assert.equal(result.accountId, "openai-backup"); + assert.equal(result.requestId, "req-master-fallback"); + + const state = await readState(); + const masterProject = state.projects.find((project) => project.id === "master-agent"); + const reply = masterProject?.messages.at(-1); + assert.ok(reply, "expected a master-agent reply to be appended"); + assert.equal(reply?.sender, "master"); + assert.equal(reply?.senderLabel, "主 Agent · 备用 GPT"); + assert.match(reply?.body ?? "", /主Agent链路正常/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/single-thread-message-execution.test.ts b/tests/single-thread-message-execution.test.ts new file mode 100644 index 0000000..15f7c89 --- /dev/null +++ b/tests/single-thread-message-execution.test.ts @@ -0,0 +1,155 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"]; +let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let AUTH_SESSION_COOKIE = ""; + +async function setup() { + if (runtimeRoot) { + return; + } + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-single-thread-message-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [messageModule, completeModule, data, auth] = await Promise.all([ + import("../src/app/api/v1/projects/[projectId]/messages/route.ts"), + import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + postMessageRoute = messageModule.POST; + completeMasterTaskRoute = completeModule.POST; + createAuthSession = data.createAuthSession; + readState = data.readState; + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +async function createAuthedRequest(url: string, method: "POST", body: unknown) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(url, { + method, + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify(body), + }); +} + +function findSingleThreadProject( + state: Awaited>, +) { + return state.projects.find((project) => project.id !== "master-agent" && !project.isGroup); +} + +test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task for single-thread projects", async () => { + await setup(); + const state = await readState(); + const singleProject = findSingleThreadProject(state); + assert.ok(singleProject, "expected a seeded single-thread project"); + + const response = await postMessageRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`, + "POST", + { body: "请同步一下当前阻塞情况" }, + ), + { params: Promise.resolve({ projectId: singleProject.id }) }, + ); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + task?: { taskId: string; taskType: string; status: string } | null; + dispatchPlan: null; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.dispatchPlan, null); + assert.ok(payload.task, "expected single-thread message to return a queued task"); + assert.equal(payload.task?.taskType, "conversation_reply"); + assert.equal(payload.task?.status, "queued"); + + const nextState = await readState(); + const task = nextState.masterAgentTasks.find( + (item) => + item.taskType === "conversation_reply" && + item.projectId === singleProject.id && + item.requestText === "请同步一下当前阻塞情况", + ); + assert.ok(task, "expected a queued conversation_reply task for the single-thread project"); + assert.equal(task?.targetProjectId, singleProject.id); + assert.equal(task?.targetThreadId, singleProject.threadMeta.threadId); +}); + +test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => { + await setup(); + const state = await readState(); + const singleProject = findSingleThreadProject(state); + assert.ok(singleProject, "expected a seeded single-thread project"); + + await postMessageRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`, + "POST", + { body: "请同步一下当前阻塞情况" }, + ), + { params: Promise.resolve({ projectId: singleProject.id }) }, + ); + + const queuedState = await readState(); + const task = queuedState.masterAgentTasks.find( + (item) => + item.taskType === "conversation_reply" && + item.projectId === singleProject.id && + item.targetProjectId === singleProject.id, + ); + assert.ok(task, "expected a queued conversation_reply task"); + + const response = await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${task.taskId}/complete`, + "POST", + { + deviceId: task.deviceId, + status: "completed", + targetProjectId: singleProject.id, + targetThreadId: singleProject.threadMeta.threadId, + replyBody: "当前阻塞点已经同步:视觉验收待今晚回归。", + }, + ), + { params: Promise.resolve({ taskId: task.taskId }) }, + ); + assert.equal(response.status, 200); + + const nextState = await readState(); + const updatedProject = nextState.projects.find((project) => project.id === singleProject.id); + const mirroredReply = updatedProject?.messages.find((message) => + message.body.includes("当前阻塞点已经同步:视觉验收待今晚回归。"), + ); + assert.ok(mirroredReply, "expected single-thread reply to be written back to the project"); + assert.equal(mirroredReply?.sender, "device"); +});