diff --git a/README.md b/README.md index 73a3816..f36ff19 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,12 @@ Android APK: - 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝`,刷新后也能恢复最近一条待确认推荐 - 当前 `approval_required` 群聊在已有待确认推荐时,会拒绝继续生成新的推荐,并提示用户先确认或拒绝当前推荐,避免审批消息叠加 - 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示 -- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号` 与 `绑定 Master Codex Node` 两条显式入口;OpenAI API 登录成功后会立即设为当前主控 +- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号`、`接入阿里百炼备用账号` 与 `绑定 Master Codex Node` 三条显式入口;OpenAI API 登录成功后会立即设为当前主控,阿里百炼账号会作为备用链路保存 - 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:会先进入原生引导页,再自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入 - 当前 `AI 账号` 页顶部会显式展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个动作,切换主控后可直接验证聊天通路 - 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已补:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆的新增、编辑、删除接口;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖 - 当前 `OpenAiOnboardingActivity` 在登录成功后会直接给出 `测试主 Agent 对话` 入口,可一键跳到 `master-agent` 聊天页 -- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,避免聊天直接掉成失败日志 +- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,避免聊天直接掉成失败日志 - 当前原生 Android 的聊天发送已改成更短的客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“思考中 / 超时 / 重试等待”状态,不再要求客户端长时间同步阻塞 - 当前群资料页已经支持“修复群成员”:如果历史脏群里混入了 `master-agent` 或失效线程引用,前台会明确提示并允许重新选择真实线程成员,修复后会正式写回群成员账本 - 当前原生聊天页也会直接提示“修复群成员”:当群里存在失效线程或不可下发成员时,`ProjectDetailActivity` 会在消息流上方直接给出 `去修复` 入口,并跳到群资料页完成修复 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 f70c030..a65301d 100644 --- a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java @@ -19,8 +19,8 @@ import org.json.JSONObject; public class AiAccountsActivity extends BossScreenActivity { private static final String[] ROLE_VALUES = {"primary", "backup", "api_fallback"}; private static final String[] ROLE_LABELS = {"主 GPT", "备用 GPT", "API 容灾"}; - private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api"}; - private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API"}; + private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api", "aliyun_qwen_api"}; + private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API", "阿里百炼 Qwen"}; private boolean refreshOnResume; @Override @@ -174,6 +174,15 @@ public class AiAccountsActivity extends BossScreenActivity { v -> openOpenAiOnboardingScreen() )); + section.addView(BossUi.buildWechatMenuRow( + this, + "接入阿里百炼备用账号", + "把阿里百炼 Qwen 兼容接口接成主 Agent 的备用链路。", + "建议模型:qwen3.5-plus 或 qwen3.5-flash。", + null, + v -> openAliyunQwenOnboardingDialog() + )); + section.addView(BossUi.buildWechatMenuRow( this, "绑定电脑上的 Codex 节点", @@ -330,6 +339,40 @@ public class AiAccountsActivity extends BossScreenActivity { .show(); } + private void openAliyunQwenOnboardingDialog() { + final EditText labelInput = BossUi.buildInput(this, "标签,例如 备用 GPT", false); + labelInput.setText("备用 GPT"); + final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false); + displayNameInput.setText("阿里百炼备用账号"); + final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false); + final EditText modelInput = BossUi.buildInput(this, "模型,例如 qwen3.5-plus", false); + modelInput.setText("qwen3.5-plus"); + final EditText apiKeyInput = BossUi.buildInput(this, "阿里百炼 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, "模型", "例如 qwen3.5-plus", modelInput)); + form.addView(BossUi.buildFormCell(this, "API Key", "填写后会保存为备用链路,不会抢占当前主控", apiKeyInput)); + + new AlertDialog.Builder(this) + .setTitle("接入阿里百炼备用账号") + .setMessage("接入成功后,这个账号会作为主 Agent 的备用模型链路,在主节点离线或失败时自动接管。") + .setView(form) + .setNegativeButton("取消", null) + .setPositiveButton("接入", (dialog, which) -> submitAliyunQwenOnboarding( + labelInput.getText().toString().trim(), + displayNameInput.getText().toString().trim(), + accountIdentifierInput.getText().toString().trim(), + modelInput.getText().toString().trim(), + apiKeyInput.getText().toString().trim() + )) + .show(); + } + private void submitOpenAiOnboarding( String label, String displayName, @@ -428,6 +471,45 @@ public class AiAccountsActivity extends BossScreenActivity { }); } + private void submitAliyunQwenOnboarding( + 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); + + BossApiClient.ApiResponse response = apiClient.onboardAliyunQwenAccount(payload); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("阿里百炼备用账号已接入。"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + String detail = error.getMessage(); + showMessage(detail == null || detail.trim().isEmpty() + ? "阿里百炼备用账号接入失败,请稍后重试。" + : "阿里百炼备用账号接入失败:" + detail); + }); + } + }); + } + private String extractAccountId(JSONObject json) { if (json == null) { return ""; @@ -486,7 +568,7 @@ public class AiAccountsActivity extends BossScreenActivity { form.addView(BossUi.buildFormCell(this, "节点 ID", "Master Codex Node 的唯一标识", nodeIdInput)); form.addView(BossUi.buildFormCell(this, "节点名称", "用于快速识别节点", nodeLabelInput)); form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput)); - form.addView(BossUi.buildFormCell(this, "API Key", "仅 OpenAI API 模式需要", apiKeyInput)); + form.addView(BossUi.buildFormCell(this, "API Key", "OpenAI API / 阿里百炼 Qwen 模式需要", apiKeyInput)); form.addView(BossUi.buildFormCell(this, "登录状态备注", "可记录 Plus、有无风控等状态", loginStatusInput)); form.addView(BossUi.buildFormCell(this, "账号角色", null, roleSpinner)); form.addView(BossUi.buildFormCell(this, "提供方", null, providerSpinner)); 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 0d42eb4..4a9d3df 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -396,6 +396,10 @@ public class BossApiClient { return onboardAccount("/api/v1/accounts/onboard/openai-api", payload); } + public ApiResponse onboardAliyunQwenAccount(JSONObject payload) throws IOException, JSONException { + return onboardAccount("/api/v1/accounts/onboard/aliyun-qwen", payload); + } + public ApiResponse onboardMasterNodeAccount(JSONObject payload) throws IOException, JSONException { return onboardAccount("/api/v1/accounts/onboard/master-node", payload); } diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 52e3549..39475fd 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -344,8 +344,8 @@ - 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用 - `projectId=master-agent` 且 `kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本 - 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete` - - 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API` 账号,避免聊天直接只剩失败日志 - - 如本机节点未接通,可切到 `OpenAI API` 容灾账号 + - 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 账号,避免聊天直接只剩失败日志 + - 如本机节点未接通,可切到 `OpenAI API` 或 `阿里百炼 Qwen` 备用账号 - 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批 - 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案 - 如果群里已经有一条待确认推荐,接口会直接返回 `409`,要求先确认或拒绝当前推荐,避免审批消息叠加 @@ -467,6 +467,21 @@ - 返回结果会带当前主控状态摘要,供原生 Android 直接弹出“测试主 Agent 对话” - 若服务器当前无法访问 `api.openai.com`,会直接返回明确中文网络错误,而不是只返回 `fetch failed` +#### `POST /api/v1/accounts/onboard/aliyun-qwen` + +- 用途:通过阿里百炼兼容接口接入 `Qwen` 备用账号 +- 输入: + - `label` + - `displayName` + - `accountIdentifier` + - `model` + - `apiKey` +- 当前行为: + - 先对候选 `API Key` 做真实阿里百炼兼容 `responses` 探针校验 + - 校验成功后创建或更新 `aliyun_qwen_api` 备用账号 + - 默认不抢占当前主控,只作为 fallback 链路保存 + - 返回当前主控摘要,方便前台立刻刷新账号状态 + #### `POST /api/v1/accounts/onboard/master-node` - 用途:显式绑定一台电脑上的 `Master Codex Node` @@ -489,9 +504,11 @@ - 当前 provider: - `master_codex_node` - `openai_api` + - `aliyun_qwen_api` - 当前产品语义: - `master_codex_node` 表示已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的执行节点 - - `openai_api` 只作为用户自行配置的 API 容灾 + - `openai_api` 作为用户自行配置的 OpenAI 主控或容灾 + - `aliyun_qwen_api` 作为用户自行配置的阿里百炼 Qwen 兼容备用链路 #### `GET /api/v1/accounts/[accountId]` @@ -515,6 +532,7 @@ - 当前行为: - `master_codex_node`:明确提示“主 GPT 不在手机里直接登录”,并同时校验绑定设备是否在线;在线时返回 `ready`,离线时返回 `degraded` - `openai_api`:实际调用模型返回探针结果 + - `aliyun_qwen_api`:实际调用阿里百炼兼容接口返回探针结果 - 当前约束: - `openai_api` 的容灾 Key 由用户在 APP 内配置,不走服务器默认预置 - 手机端当前没有直接的 ChatGPT OAuth 登录流程;主 GPT 必须先在绑定电脑上的 Codex / ChatGPT Plus 会话里完成登录 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index aebd14a..93fb703 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -14,6 +14,7 @@ - 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 账号阿里备用接入接口:`POST http://127.0.0.1:3000/api/v1/accounts/onboard/aliyun-qwen` - 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` @@ -107,12 +108,12 @@ cd /Users/kris/code/boss - 当前 `approval_required` 群聊在已经存在一条 `pending_user_confirmation` 推荐时,会拒绝继续创建新的推荐并返回 `409`,前台会提示用户先确认或拒绝当前推荐 - 当前普通单线程聊天也已补上真实执行链:`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 登录成功后会立即切成当前主控 +- 当前 `AI 账号` 页面已分成三条显式接入链:`登录 OpenAI 平台账号(API Key)`、`接入阿里百炼备用账号` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控,阿里百炼账号会作为备用链路保存 - 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入 - 当前 `OpenAiOnboardingActivity` 在登录成功后会直接弹出 `测试主 Agent 对话`,可一键进入 `master-agent` 聊天页验证主控链路 - 当前 `AI 账号` 页顶部会直接展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个入口,切换主控后不必再手动退回会话页验证 - 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已接通:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆都可以在 Web 端查看和编辑;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖 -- 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,不再把失败日志直接原样回给用户 +- 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,不再把失败日志直接原样回给用户 - 当前原生 Android 的聊天发送已收短客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“主 Agent 思考中 / 回复超时 / 重试等待”状态,不再要求客户端长时间同步阻塞 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 diff --git a/src/app/api/v1/accounts/[accountId]/route.ts b/src/app/api/v1/accounts/[accountId]/route.ts index 4e08582..c330df5 100644 --- a/src/app/api/v1/accounts/[accountId]/route.ts +++ b/src/app/api/v1/accounts/[accountId]/route.ts @@ -6,8 +6,8 @@ function isValidRole(value: string): value is "primary" | "backup" | "api_fallba return value === "primary" || value === "backup" || value === "api_fallback"; } -function isValidProvider(value: string): value is "master_codex_node" | "openai_api" { - return value === "master_codex_node" || value === "openai_api"; +function isValidProvider(value: string): value is "master_codex_node" | "openai_api" | "aliyun_qwen_api" { + return value === "master_codex_node" || value === "openai_api" || value === "aliyun_qwen_api"; } export async function GET( diff --git a/src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts b/src/app/api/v1/accounts/onboard/aliyun-qwen/route.ts new file mode 100644 index 0000000..8c25604 --- /dev/null +++ b/src/app/api/v1/accounts/onboard/aliyun-qwen/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 { probeApiCompatibleAccount } from "@/lib/boss-master-agent"; + +function chooseAliyunBackupAccountId(state: Awaited>) { + return ( + state.aiAccounts.find((item) => item.provider === "aliyun_qwen_api" && item.role === "backup")?.accountId || + "aliyun-qwen-backup" + ); +} + +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: "请先填写阿里百炼 API Key。" }, { status: 400 }); + } + + try { + const probe = await probeApiCompatibleAccount({ + provider: "aliyun_qwen_api", + apiKey: body.apiKey, + model: body.model, + }); + + const state = await readState(); + const accountId = chooseAliyunBackupAccountId(state); + const account = await saveAiAccount({ + accountId, + label: body.label?.trim() || "备用 GPT", + role: "backup", + provider: "aliyun_qwen_api", + displayName: body.displayName.trim(), + accountIdentifier: body.accountIdentifier?.trim() || undefined, + model: probe.model, + apiKey: body.apiKey.trim(), + enabled: true, + setActive: false, + loginStatusNote: "已接入阿里百炼 Qwen 兼容接口,可作为主 Agent 的备用模型链路。", + }); + + await updateAiAccountHealth({ + accountId: account.accountId, + status: "ready", + lastError: undefined, + lastValidatedAt: new Date().toISOString(), + }); + + const nextState = await readState(); + return NextResponse.json({ + ok: true, + accountId: account.accountId, + account, + activeIdentity: getMasterIdentitySummaryFromState(nextState), + requestId: probe.requestId, + message: "阿里百炼备用账号已接入,可作为主 Agent 的备用链路。", + }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "ALIYUN_QWEN_ONBOARD_FAILED" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/v1/accounts/route.ts b/src/app/api/v1/accounts/route.ts index a2119c5..43c6fda 100644 --- a/src/app/api/v1/accounts/route.ts +++ b/src/app/api/v1/accounts/route.ts @@ -6,8 +6,8 @@ function isValidRole(value: string): value is "primary" | "backup" | "api_fallba return value === "primary" || value === "backup" || value === "api_fallback"; } -function isValidProvider(value: string): value is "master_codex_node" | "openai_api" { - return value === "master_codex_node" || value === "openai_api"; +function isValidProvider(value: string): value is "master_codex_node" | "openai_api" | "aliyun_qwen_api" { + return value === "master_codex_node" || value === "openai_api" || value === "aliyun_qwen_api"; } export async function GET(request: NextRequest) { diff --git a/src/components/ai-accounts-client.tsx b/src/components/ai-accounts-client.tsx index 12ae8fb..41ace24 100644 --- a/src/components/ai-accounts-client.tsx +++ b/src/components/ai-accounts-client.tsx @@ -26,7 +26,7 @@ type AccountDraft = { loginStatusNote: string; }; -type OnboardingMode = "openai_api" | "master_codex_node" | null; +type OnboardingMode = "openai_api" | "aliyun_qwen_api" | "master_codex_node" | null; type OpenAiOnboardDraft = { label: string; @@ -36,6 +36,14 @@ type OpenAiOnboardDraft = { apiKey: string; }; +type AliyunQwenOnboardDraft = { + label: string; + displayName: string; + accountIdentifier: string; + model: string; + apiKey: string; +}; + type MasterNodeOnboardDraft = { label: string; displayName: string; @@ -56,6 +64,7 @@ function roleOptions() { function providerOptions() { return [ { value: "openai_api", label: "OpenAI API" }, + { value: "aliyun_qwen_api", label: "阿里百炼 Qwen" }, { value: "master_codex_node", label: "Master Codex Node / ChatGPT Plus 节点" }, ] as const; } @@ -70,6 +79,16 @@ function defaultOpenAiOnboardDraft(): OpenAiOnboardDraft { }; } +function defaultAliyunQwenOnboardDraft(): AliyunQwenOnboardDraft { + return { + label: "备用 GPT", + displayName: "阿里百炼备用账号", + accountIdentifier: "", + model: "qwen3.5-plus", + apiKey: "", + }; +} + function defaultMasterNodeOnboardDraft(): MasterNodeOnboardDraft { return { label: "主 GPT", @@ -184,6 +203,8 @@ export function AiAccountsClient({ const [guideAccountId, setGuideAccountId] = useState(null); const [onboardingMode, setOnboardingMode] = useState(null); const [openAiOnboardDraft, setOpenAiOnboardDraft] = useState(defaultOpenAiOnboardDraft()); + const [aliyunQwenOnboardDraft, setAliyunQwenOnboardDraft] = + useState(defaultAliyunQwenOnboardDraft()); const [masterNodeOnboardDraft, setMasterNodeOnboardDraft] = useState( defaultMasterNodeOnboardDraft(), ); @@ -296,6 +317,37 @@ export function AiAccountsClient({ setMessage(result.message || "OpenAI 平台账号登录失败。"); } + async function submitAliyunQwenOnboarding() { + const draft = aliyunQwenOnboardDraft; + if (!draft.displayName.trim() || !draft.apiKey.trim()) { + setMessage("请先填写阿里百炼备用账号名称和 API Key。"); + return; + } + + setBusyKey("onboard:aliyun_qwen_api"); + const response = await fetch("/api/v1/accounts/onboard/aliyun-qwen", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + label: draft.label.trim() || "备用 GPT", + displayName: draft.displayName.trim(), + accountIdentifier: draft.accountIdentifier.trim(), + model: draft.model.trim() || "qwen3.5-plus", + apiKey: draft.apiKey.trim(), + }), + }); + const result = (await response.json()) as { ok: boolean; message?: string }; + setBusyKey(null); + if (result.ok) { + setMessage(result.message || "阿里百炼备用账号已接入。"); + setAliyunQwenOnboardDraft(defaultAliyunQwenOnboardDraft()); + closeOnboarding(); + router.refresh(); + return; + } + setMessage(result.message || "阿里百炼备用账号接入失败。"); + } + async function submitMasterNodeOnboarding() { const draft = masterNodeOnboardDraft; if (!draft.displayName.trim() || !draft.nodeId.trim()) { @@ -330,7 +382,9 @@ export function AiAccountsClient({ } function onboardingTitle() { - return onboardingMode === "openai_api" ? "登录 OpenAI 平台账号" : "绑定电脑上的 Codex 节点"; + if (onboardingMode === "openai_api") return "登录 OpenAI 平台账号"; + if (onboardingMode === "aliyun_qwen_api") return "接入阿里百炼备用账号"; + return "绑定电脑上的 Codex 节点"; } return ( @@ -399,6 +453,30 @@ export function AiAccountsClient({ 入口字段:显示名称、账号标识、模型、API Key。 + + ) : onboardingMode === "aliyun_qwen_api" ? ( +
+ setAliyunQwenOnboardDraft((current) => ({ ...current, label: value }))} + placeholder="例如:备用 GPT" + /> + + setAliyunQwenOnboardDraft((current) => ({ ...current, displayName: value })) + } + placeholder="例如:阿里百炼备用账号" + /> + + setAliyunQwenOnboardDraft((current) => ({ ...current, accountIdentifier: value })) + } + placeholder="例如:dashscope 账号备注" + /> + + setAliyunQwenOnboardDraft((current) => ({ ...current, model: value })) + } + placeholder="例如:qwen3.5-plus" + /> + setAliyunQwenOnboardDraft((current) => ({ ...current, apiKey: value }))} + placeholder="输入阿里百炼 API Key" + type="password" + /> +
+ + +
+
) : (
AI 账号”至少配置一个可用的 Master Codex Node 或 OpenAI API 账号。", + note: "请到“我的 > AI 账号”至少配置一个可用的 Master Codex Node、OpenAI API 或阿里百炼 Qwen 账号。", }; } @@ -4662,6 +4668,12 @@ export async function saveAiAccount(payload: { existing?.accountId ?? payload.accountId?.trim() ?? `ai-${slugify(`${payload.label}-${payload.displayName}`)}`; + const defaultModel = + payload.provider === "aliyun_qwen_api" + ? "qwen3.5-plus" + : payload.provider === "openai_api" + ? "gpt-5.4" + : undefined; const next: AiAccount = normalizeAiAccount({ accountId, label: payload.label.trim() || aiRoleLabel(payload.role), @@ -4671,21 +4683,21 @@ export async function saveAiAccount(payload: { accountIdentifier: payload.accountIdentifier?.trim() || undefined, nodeId: payload.nodeId?.trim() || undefined, nodeLabel: payload.nodeLabel?.trim() || undefined, - model: payload.model?.trim() || (payload.provider === "openai_api" ? "gpt-5.4" : undefined), + model: payload.model?.trim() || defaultModel, apiKey: - payload.provider === "openai_api" + isApiKeyProvider(payload.provider) ? payload.apiKey?.trim() ? payload.apiKey.trim() : existing?.apiKey : undefined, apiKeyMasked: - payload.provider === "openai_api" + isApiKeyProvider(payload.provider) ? maskApiKey(payload.apiKey?.trim() || existing?.apiKey) : undefined, enabled: payload.enabled ?? existing?.enabled ?? true, isActive: existing?.isActive ?? false, status: - payload.provider === "openai_api" + isApiKeyProvider(payload.provider) ? payload.apiKey?.trim() || existing?.apiKey ? existing?.status === "degraded" ? "degraded" diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index f7657b4..a9a34a6 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -19,7 +19,7 @@ import { updateAttachmentAnalysisResult, updateAiAccountHealth, } from "@/lib/boss-data"; -import type { DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort } from "@/lib/boss-data"; +import type { AiProvider, DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort } 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"; @@ -31,6 +31,32 @@ import { type MasterAgentReplyState = "queued" | "running" | "completed"; const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai"; +const ALIYUN_QWEN_DEVICE_ID = "master-agent-aliyun-qwen"; + +type ApiCompatibleProvider = Extract; + +const API_PROVIDER_CONFIG: Record< + ApiCompatibleProvider, + { + label: string; + endpoint: string; + defaultModel: string; + loginLabel: string; + } +> = { + openai_api: { + label: "OpenAI API", + endpoint: "https://api.openai.com/v1/responses", + defaultModel: "gpt-5.4", + loginLabel: "OpenAI API Key", + }, + aliyun_qwen_api: { + label: "阿里百炼 Qwen", + endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/responses", + defaultModel: "qwen3.5-plus", + loginLabel: "阿里百炼 API Key", + }, +}; type QueuedMasterAgentReplyEnvelope = { ok: true; @@ -378,6 +404,51 @@ function normalizeOpenAiFetchFailure(error: unknown) { return normalizeOpenAiError(String(error)); } +function isApiCompatibleProvider(provider: AiProvider): provider is ApiCompatibleProvider { + return provider === "openai_api" || provider === "aliyun_qwen_api"; +} + +function apiProviderConfig(provider: ApiCompatibleProvider) { + return API_PROVIDER_CONFIG[provider]; +} + +function normalizeApiProviderError(provider: ApiCompatibleProvider, message: string) { + if (provider === "openai_api") { + return normalizeOpenAiError(message); + } + + const trimmed = message.trim(); + const lowered = trimmed.toLowerCase(); + if ( + lowered.includes("network is unreachable") || + lowered.includes("enetunreach") || + lowered.includes("timed out") || + lowered.includes("fetch failed") || + lowered.includes("connect timeout") + ) { + return "服务器当前无法连接阿里百炼兼容接口,请检查出网、代理或防火墙配置。"; + } + if (!trimmed) return "主 Agent 当前调用阿里百炼模型失败。"; + if (trimmed.length <= 240) return trimmed; + return `${trimmed.slice(0, 237)}...`; +} + +function normalizeApiProviderFetchFailure(provider: ApiCompatibleProvider, error: unknown) { + if (provider === "openai_api") { + return normalizeOpenAiFetchFailure(error); + } + 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 normalizeApiProviderError(provider, [error.message, causeCode, causeMessage].filter(Boolean).join(" ")); + } + return normalizeApiProviderError(provider, String(error)); +} + function fallbackAiRolePriority(role: "primary" | "backup" | "api_fallback") { switch (role) { case "primary": @@ -391,14 +462,14 @@ function fallbackAiRolePriority(role: "primary" | "backup" | "api_fallback") { } } -async function findFallbackOpenAiAccount(excludedAccountId?: string) { +async function findFallbackApiAccount(excludedAccountId?: string) { const state = await readState(); return [...state.aiAccounts] .filter( (account) => account.accountId !== excludedAccountId && account.enabled && - account.provider === "openai_api" && + isApiCompatibleProvider(account.provider) && Boolean(account.apiKey?.trim()), ) .sort((left, right) => { @@ -409,7 +480,7 @@ async function findFallbackOpenAiAccount(excludedAccountId?: string) { } async function replyViaOpenAiAccount(params: { - account: Awaited>; + account: Awaited>; requestText: string; currentSessionExpiresAt?: string; senderLabel: string; @@ -419,13 +490,17 @@ async function replyViaOpenAiAccount(params: { projectMemories?: Awaited>; userMemories?: Awaited>; }) { - if (!params.account?.apiKey?.trim()) { + if (!params.account?.apiKey?.trim() || !isApiCompatibleProvider(params.account.provider)) { throw new Error("OPENAI_ACCOUNT_NOT_CONFIGURED"); } - const generated = await generateOpenAiReply({ + const generated = await generateApiProviderReply({ + provider: params.account.provider, apiKey: params.account.apiKey, - model: params.agentControls?.modelOverride || params.account.model || "gpt-5.4", + model: + params.agentControls?.modelOverride || + params.account.model || + apiProviderConfig(params.account.provider).defaultModel, reasoningEffort: params.agentControls?.reasoningEffortOverride || "medium", requestText: params.requestText, currentSessionExpiresAt: params.currentSessionExpiresAt, @@ -451,7 +526,8 @@ async function replyViaOpenAiAccount(params: { }; } -async function generateOpenAiReply(params: { +async function generateApiProviderReply(params: { + provider: ApiCompatibleProvider; apiKey: string; model: string; reasoningEffort: ReasoningEffort; @@ -475,34 +551,38 @@ async function generateOpenAiReply(params: { params.requestText, ); let response: Response; + const config = apiProviderConfig(params.provider); + const requestBody: Record = { + model: params.model, + instructions: buildMasterAgentExecutionPrompt({ + state, + projectId: "master-agent", + requestText: params.requestText, + currentSessionExpiresAt: params.currentSessionExpiresAt, + agentControls: params.agentControls, + accountId: "master-agent", + promptPolicy: params.promptPolicy ?? null, + userPrompt: params.userPrompt ?? null, + projectMemories: effectiveProjectMemories, + userMemories: params.userMemories ?? [], + }), + input: params.requestText, + }; + if (params.provider === "openai_api") { + requestBody.reasoning = { effort: params.reasoningEffort }; + } try { - response = await fetch("https://api.openai.com/v1/responses", { + response = await fetch(config.endpoint, { method: "POST", headers: { Authorization: `Bearer ${params.apiKey}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - model: params.model, - reasoning: { effort: params.reasoningEffort }, - instructions: buildMasterAgentExecutionPrompt({ - state, - projectId: "master-agent", - requestText: params.requestText, - currentSessionExpiresAt: params.currentSessionExpiresAt, - agentControls: params.agentControls, - accountId: "master-agent", - promptPolicy: params.promptPolicy ?? null, - userPrompt: params.userPrompt ?? null, - projectMemories: effectiveProjectMemories, - userMemories: params.userMemories ?? [], - }), - input: params.requestText, - }), + body: JSON.stringify(requestBody), signal: AbortSignal.timeout(45_000), }); } catch (error) { - throw new Error(normalizeOpenAiFetchFailure(error)); + throw new Error(normalizeApiProviderFetchFailure(params.provider, error)); } const requestId = response.headers.get("x-request-id") ?? undefined; @@ -516,8 +596,9 @@ async function generateOpenAiReply(params: { ? payload.error?.message : undefined; throw new Error( - normalizeOpenAiError( - `${apiError ?? `OpenAI API ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`, + normalizeApiProviderError( + params.provider, + `${apiError ?? `${config.label} ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`, ), ); } @@ -525,7 +606,8 @@ async function generateOpenAiReply(params: { const content = extractResponseText(payload); if (!content) { throw new Error( - normalizeOpenAiError( + normalizeApiProviderError( + params.provider, `模型已返回成功状态,但没有可用文本输出${requestId ? ` (request_id=${requestId})` : ""}`, ), ); @@ -562,6 +644,7 @@ function buildMasterOpenAiReplyPrompt( } async function queueAndStartOpenAiMasterAgentReply(params: { + provider: ApiCompatibleProvider; taskId: string; deviceId: string; requestText: string; @@ -583,7 +666,8 @@ async function queueAndStartOpenAiMasterAgentReply(params: { } try { - const generated = await generateOpenAiReply({ + const generated = await generateApiProviderReply({ + provider: params.provider, apiKey: params.apiKey, model: params.model, reasoningEffort: params.reasoningEffort, @@ -617,6 +701,7 @@ async function queueAndStartOpenAiMasterAgentReply(params: { } async function enqueueOpenAiMasterAgentReply(params: { + provider: ApiCompatibleProvider; accountId: string; accountLabel: string; requestMessageId?: string; @@ -649,13 +734,14 @@ async function enqueueOpenAiMasterAgentReply(params: { ), requestedBy: params.requestedBy, requestedByAccount: params.requestedByAccount, - deviceId: OPENAI_MASTER_AGENT_DEVICE_ID, + deviceId: params.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID, accountId: params.accountId, accountLabel: params.accountLabel, }); void queueAndStartOpenAiMasterAgentReply({ + provider: params.provider, taskId: task.taskId, - deviceId: OPENAI_MASTER_AGENT_DEVICE_ID, + deviceId: params.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID, requestText: params.requestText, currentSessionExpiresAt: params.currentSessionExpiresAt, apiKey: params.apiKey, @@ -682,34 +768,39 @@ async function enqueueOpenAiMasterAgentReply(params: { return queuedReply; } -export async function probeOpenAiApiAccount(params: { +export async function probeApiCompatibleAccount(params: { + provider: ApiCompatibleProvider; apiKey: string; model?: string; }) { const apiKey = params.apiKey.trim(); if (!apiKey) { - throw new Error("当前账号还没有可用的 OpenAI API Key。"); + throw new Error(`当前账号还没有可用的 ${apiProviderConfig(params.provider).loginLabel}。`); } - const model = params.model?.trim() || "gpt-5.4"; + const config = apiProviderConfig(params.provider); + const model = params.model?.trim() || config.defaultModel; let response: Response; + const body: Record = { + model, + instructions: `你正在执行${config.label}连接自检。请只回复“连接正常”。`, + input: "请只回复“连接正常”。", + }; + if (params.provider === "openai_api") { + body.reasoning = { effort: "low" }; + } try { - response = await fetch("https://api.openai.com/v1/responses", { + response = await fetch(config.endpoint, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - model, - reasoning: { effort: "low" }, - instructions: "你正在执行 OpenAI API 连接自检。请只回复“连接正常”。", - input: "请只回复“连接正常”。", - }), + body: JSON.stringify(body), signal: AbortSignal.timeout(15_000), }); } catch (error) { - throw new Error(normalizeOpenAiFetchFailure(error)); + throw new Error(normalizeApiProviderFetchFailure(params.provider, error)); } const requestId = response.headers.get("x-request-id") ?? undefined; @@ -723,8 +814,9 @@ export async function probeOpenAiApiAccount(params: { ? payload.error?.message : undefined; throw new Error( - normalizeOpenAiError( - `${apiError ?? `OpenAI API ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`, + normalizeApiProviderError( + params.provider, + `${apiError ?? `${config.label} ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`, ), ); } @@ -738,6 +830,13 @@ export async function probeOpenAiApiAccount(params: { }; } +export async function probeOpenAiApiAccount(params: { apiKey: string; model?: string }) { + return probeApiCompatibleAccount({ + provider: "openai_api", + ...params, + }); +} + async function appendMasterAgentSystemReply(body: string, senderLabel = "主 Agent") { return appendProjectMessage({ projectId: "master-agent", @@ -1332,17 +1431,18 @@ export async function validateAiAccountConnection(accountId: string) { }; } - if (account.provider !== "openai_api" || !account.apiKey?.trim()) { + if (!isApiCompatibleProvider(account.provider) || !account.apiKey?.trim()) { return { ok: false as const, status: "needs_api_key", - message: "当前账号还没有可用的 OpenAI API Key。", + message: `当前账号还没有可用的${isApiCompatibleProvider(account.provider) ? apiProviderConfig(account.provider).loginLabel : " API Key"}。`, }; } - const generated = await probeOpenAiApiAccount({ + const generated = await probeApiCompatibleAccount({ + provider: account.provider, apiKey: account.apiKey, - model: account.model || "gpt-5.4", + model: account.model || apiProviderConfig(account.provider).defaultModel, }); await updateAiAccountHealth({ @@ -1372,7 +1472,7 @@ export async function replyToMasterAgentUserMessage(params: { if (!runtime?.account) { await appendMasterAgentSystemReply( - "我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 OpenAI API 账号,再继续对话。", + "我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 OpenAI API、阿里百炼 Qwen,或接回 Master Codex Node 后,再继续对话。", ); return { ok: false as const, reason: "NO_AI_ACCOUNT" }; } @@ -1403,9 +1503,10 @@ export async function replyToMasterAgentUserMessage(params: { lastValidatedAt: new Date().toISOString(), }); - const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId); - if (fallbackAccount?.apiKey?.trim()) { + const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId); + if (fallbackAccount?.apiKey?.trim() && isApiCompatibleProvider(fallbackAccount.provider)) { return enqueueOpenAiMasterAgentReply({ + provider: fallbackAccount.provider, accountId: fallbackAccount.accountId, accountLabel: fallbackAccount.label || aiRoleLabel(fallbackAccount.role), requestMessageId: params.requestMessageId, @@ -1414,7 +1515,10 @@ export async function replyToMasterAgentUserMessage(params: { requestedByAccount: params.requestedByAccount, currentSessionExpiresAt: params.currentSessionExpiresAt, apiKey: fallbackAccount.apiKey, - model: agentControls?.modelOverride || fallbackAccount.model || "gpt-5.4", + model: + agentControls?.modelOverride || + fallbackAccount.model || + apiProviderConfig(fallbackAccount.provider).defaultModel, reasoningEffort: agentControls?.reasoningEffortOverride || "medium", agentControls, promptPolicy: executionConfig.promptPolicy, @@ -1465,8 +1569,9 @@ export async function replyToMasterAgentUserMessage(params: { return queuedReply; } - if (runtime.account.provider === "openai_api" && runtime.account.apiKey?.trim()) { + if (isApiCompatibleProvider(runtime.account.provider) && runtime.account.apiKey?.trim()) { return enqueueOpenAiMasterAgentReply({ + provider: runtime.account.provider, accountId: runtime.account.accountId, accountLabel: runtime.account.label || runtime.summary.roleLabel, requestMessageId: params.requestMessageId, @@ -1503,7 +1608,7 @@ 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); + const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId); if (fallbackAccount) { try { return await replyViaOpenAiAccount({ @@ -1557,7 +1662,7 @@ export async function replyToMasterAgentUserMessage(params: { }; } if (completedTask?.status === "failed") { - const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId); + const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId); if (fallbackAccount) { try { return await replyViaOpenAiAccount({ @@ -1593,12 +1698,12 @@ export async function replyToMasterAgentUserMessage(params: { return { ok: true as const, accountId: runtime.account.accountId, taskId: task.taskId }; } - if (runtime.account.provider !== "openai_api" || !runtime.account.apiKey?.trim()) { + if (!isApiCompatibleProvider(runtime.account.provider) || !runtime.account.apiKey?.trim()) { await appendMasterAgentSystemReply( [ `当前主控身份是 ${runtime.summary.roleLabel},来源 ${aiProviderLabel(runtime.account.provider)}。`, - "当前账号既没有接入 Master Codex Node 执行器,也没有可用的 OpenAI API Key。", - "请到“我的 > AI 账号”补一个可用的 OpenAI API 账号,或者把当前节点接回 Master Codex Node relay。", + "当前账号既没有接入 Master Codex Node 执行器,也没有可用的 API 兼容账号。", + "请到“我的 > AI 账号”补一个可用的 OpenAI API 或阿里百炼 Qwen 账号,或者把当前节点接回 Master Codex Node relay。", ].join(""), `主 Agent · ${runtime.summary.roleLabel}`, ); @@ -1606,7 +1711,8 @@ export async function replyToMasterAgentUserMessage(params: { } try { - const generated = await generateOpenAiReply({ + const generated = await generateApiProviderReply({ + provider: runtime.account.provider, apiKey: runtime.account.apiKey, model: executionConfig.model, reasoningEffort: executionConfig.reasoningEffort, diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index a8a29e2..501ab69 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -234,6 +234,8 @@ function aiProviderLabel(provider: AiProvider) { return "Master Codex Node"; case "openai_api": return "OpenAI API"; + case "aliyun_qwen_api": + return "阿里百炼 Qwen"; default: return provider; } diff --git a/tests/ai-account-onboarding.test.ts b/tests/ai-account-onboarding.test.ts index afd434c..72161a8 100644 --- a/tests/ai-account-onboarding.test.ts +++ b/tests/ai-account-onboarding.test.ts @@ -7,6 +7,7 @@ import { NextRequest } from "next/server"; let runtimeRoot = ""; let openAiOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/openai-api/route"))["POST"]; +let aliyunQwenOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/aliyun-qwen/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"]; @@ -19,14 +20,16 @@ async function setup() { 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([ + const [openAiModule, aliyunModule, masterNodeModule, data, auth] = await Promise.all([ import("../src/app/api/v1/accounts/onboard/openai-api/route.ts"), + import("../src/app/api/v1/accounts/onboard/aliyun-qwen/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; + aliyunQwenOnboardRoute = aliyunModule.POST; masterNodeOnboardRoute = masterNodeModule.POST; createAuthSession = data.createAuthSession; readState = data.readState; @@ -188,3 +191,61 @@ test("POST /api/v1/accounts/onboard/openai-api returns a clear network guidance globalThis.fetch = originalFetch; } }); + +test("POST /api/v1/accounts/onboard/aliyun-qwen creates a backup aliyun qwen account", async () => { + await setup(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/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-aliyun", + }, + }); + } + throw new Error(`unexpected fetch: ${String(input)}`); + }) as typeof fetch; + + try { + const response = await aliyunQwenOnboardRoute( + await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/aliyun-qwen", { + label: "阿里备用", + displayName: "阿里百炼备用账号", + accountIdentifier: "dashscope-demo", + model: "qwen3.5-plus", + apiKey: "sk-aliyun-demo-123456", + }), + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + accountId: string; + message: string; + account: { provider: string; role: string; model: string; isActive: boolean }; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.accountId, "aliyun-qwen-backup"); + assert.equal(payload.account.provider, "aliyun_qwen_api"); + assert.equal(payload.account.role, "backup"); + assert.equal(payload.account.model, "qwen3.5-plus"); + assert.equal(payload.account.isActive, false); + assert.match(payload.message, /备用/); + assert.match(payload.message, /阿里|百炼/); + + const state = await readState(); + const account = state.aiAccounts.find((item) => item.accountId === "aliyun-qwen-backup"); + assert.ok(account, "expected aliyun backup account to be created"); + assert.equal(account?.provider, "aliyun_qwen_api"); + assert.equal(account?.role, "backup"); + assert.equal(account?.model, "qwen3.5-plus"); + assert.equal(account?.apiKey, "sk-aliyun-demo-123456"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/ai-account-validation.test.ts b/tests/ai-account-validation.test.ts index 2292eaf..f0ab0dc 100644 --- a/tests/ai-account-validation.test.ts +++ b/tests/ai-account-validation.test.ts @@ -9,6 +9,7 @@ let runtimeRoot = ""; let validateAiAccountConnection: (typeof import("../src/lib/boss-master-agent"))["validateAiAccountConnection"]; let readState: (typeof import("../src/lib/boss-data"))["readState"]; let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"]; +let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"]; async function setup() { if (runtimeRoot) return; @@ -25,6 +26,7 @@ async function setup() { validateAiAccountConnection = masterAgent.validateAiAccountConnection; readState = data.readState; + saveAiAccount = data.saveAiAccount; deviceHeartbeatRoute = heartbeatModule.POST; } @@ -77,3 +79,42 @@ test("validateAiAccountConnection reports degraded when the bound master node de assert.equal(result.status, "degraded"); assert.match(result.message, /当前不在线|不在线/); }); + +test("validateAiAccountConnection probes aliyun qwen backup accounts through the compatible endpoint", async () => { + await setup(); + + await saveAiAccount({ + accountId: "aliyun-qwen-backup", + label: "阿里备用", + role: "backup", + provider: "aliyun_qwen_api", + displayName: "阿里百炼备用账号", + accountIdentifier: "dashscope-demo", + model: "qwen3.5-plus", + apiKey: "sk-aliyun-demo-123456", + enabled: true, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/v1/responses") { + return new Response(JSON.stringify({ output_text: "连接正常" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-request-id": "req-aliyun-validate", + }, + }); + } + throw new Error(`unexpected fetch: ${String(input)}`); + }) as typeof fetch; + + try { + const result = await validateAiAccountConnection("aliyun-qwen-backup"); + assert.equal(result.ok, true); + assert.equal(result.status, "ready"); + assert.match(result.message, /连接正常/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/master-agent-openai-fallback.test.ts b/tests/master-agent-openai-fallback.test.ts index 689631f..d69033b 100644 --- a/tests/master-agent-openai-fallback.test.ts +++ b/tests/master-agent-openai-fallback.test.ts @@ -100,3 +100,72 @@ test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account globalThis.fetch = originalFetch; } }); + +test("replyToMasterAgentUserMessage falls back to a runnable aliyun qwen backup 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: "aliyun-qwen-backup", + label: "阿里备用", + role: "backup", + provider: "aliyun_qwen_api", + displayName: "阿里百炼备用账号", + accountIdentifier: "dashscope-demo", + model: "qwen3.5-plus", + apiKey: "sk-aliyun-demo-123456", + enabled: true, + setActive: false, + loginStatusNote: "阿里百炼 Qwen 备用账号。", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/v1/responses") { + return new Response(JSON.stringify({ output_text: "阿里备用链路正常。" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-request-id": "req-master-aliyun-fallback", + }, + }); + } + throw new Error(`unexpected fetch: ${String(input)}`); + }) as typeof fetch; + + try { + const result = await replyToMasterAgentUserMessage({ + requestMessageId: "msg-master-aliyun-fallback", + requestText: "请只回复:阿里备用链路正常。", + requestedBy: "Boss 超级管理员", + requestedByAccount: "17600003315", + }); + + assert.equal(result.ok, true); + assert.equal(result.accountId, "aliyun-qwen-backup"); + assert.equal(result.requestId, "req-master-aliyun-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 · 阿里备用"); + assert.match(reply?.body ?? "", /阿里备用链路正常/); + } finally { + globalThis.fetch = originalFetch; + } +});