diff --git a/desktop/ui/src/cloud-options.test.ts b/desktop/ui/src/cloud-options.test.ts index dacaaf68..60fc4cc5 100644 --- a/desktop/ui/src/cloud-options.test.ts +++ b/desktop/ui/src/cloud-options.test.ts @@ -56,6 +56,7 @@ describe("云端创建选项", () => { expect(groups.map((group) => group.label)).toEqual([ "基础模型", "专业模型", + "旗舰模型", "付费模型", "我的模型", "研发团队", @@ -66,6 +67,21 @@ describe("云端创建选项", () => { expect(pickDefaultCloudModel(models, "pro")).toBe("pro"); }); + it("超会员档模型展示但打 locked,默认值不落在灰条目上", () => { + const groups = groupCloudModels(models, "pro"); + + const ultra = groups.find((group) => group.label === "旗舰模型")?.models ?? []; + expect(ultra.map((model) => model.id)).toEqual(["ultra"]); + expect(ultra[0].locked).toBe(true); + expect(groups.find((group) => group.label === "专业模型")?.models[0].locked).toBeUndefined(); + + // 订阅读取失败(plan="")时专业/旗舰全灰而非消失,默认值回落基础档 + expect(groupCloudModels(models, "").map((g) => g.label)).toContain("旗舰模型"); + expect(pickDefaultCloudModel(models, "")).toBe("basic"); + // 只剩超档模型时宁空不默认选禁用项 + expect(pickDefaultCloudModel([models[3]], "basic")).toBe(""); + }); + it("手动仓库兼容 HTTPS 和 SSH 地址,并生成简短名称", () => { expect(validCloudRepoUrl("https://github.com/openai/codex.git")).toBe(true); expect(validCloudRepoUrl("ssh://git@example.com/team/repo.git")).toBe(true); diff --git a/desktop/ui/src/cloud.ts b/desktop/ui/src/cloud.ts index 7536744e..6e3342ee 100644 --- a/desktop/ui/src/cloud.ts +++ b/desktop/ui/src/cloud.ts @@ -11,6 +11,8 @@ export interface McCloudModel { is_default?: boolean; is_hidden?: boolean; owner?: { type?: "private" | "public" | "team"; id?: string; name?: string }; + /** 超会员档:展示但禁选(升级解锁);usableCloudModels 派生,非服务端字段 */ + locked?: boolean; } export interface McCloudModelGroup { @@ -176,10 +178,12 @@ const byWeightThenName = (a: McCloudModel, b: McCloudModel) => { return w !== 0 ? w : (a.model || "").localeCompare(b.model || ""); }; -/** 可选模型:有 id、非裸内置占位项、未隐藏、会员档允许。 */ +/** 可选模型:有 id、非裸内置占位项、未隐藏。超会员档不再剔除,打 locked + * 灰态展示(对齐本地选择器与 Web 端 canUseModelBySubscription 的做法)。 */ export function usableCloudModels(models: McCloudModel[], plan?: string): McCloudModel[] { return models - .filter((m) => m.id && m.model && !m.is_hidden && !BUILTIN_META.has(m.model.toLowerCase()) && planAllowsModel(m, plan)) + .filter((m) => m.id && m.model && !m.is_hidden && !BUILTIN_META.has(m.model.toLowerCase())) + .map((m) => (planAllowsModel(m, plan) ? m : { ...m, locked: true })) .sort(byWeightThenName); } @@ -214,9 +218,10 @@ export function groupCloudModels(models: McCloudModel[], plan?: string): McCloud ].filter((group) => group.models.length > 0); } -/** 默认模型:会员档匹配的内置档 weight 最高 → 公共模型 → 任意可用。 */ +/** 默认模型:会员档匹配的内置档 weight 最高 → 公共模型 → 任意可用。 + * locked 条目只展示不参与默认值(宁空不默认选禁用项)。 */ export function pickDefaultCloudModel(models: McCloudModel[], plan?: string): string { - const pool = usableCloudModels(models, plan); + const pool = usableCloudModels(models, plan).filter((m) => !m.locked); const planBuiltin = plan === "pro" ? "monkeycode-pro" : plan === "flagship" || plan === "ultra" ? "monkeycode-ultra" : "monkeycode-basic"; const planModel = pool .filter((m) => builtinName(m.model) === planBuiltin) diff --git a/desktop/ui/src/cloudModelMenu.test.tsx b/desktop/ui/src/cloudModelMenu.test.tsx index 05a8c4aa..aca9d40f 100644 --- a/desktop/ui/src/cloudModelMenu.test.tsx +++ b/desktop/ui/src/cloudModelMenu.test.tsx @@ -33,4 +33,22 @@ describe("CloudModelGroups(newtask 建任务 / cloudtask 切换共用)", () => { expect(html).toContain('aria-current="true"'); expect(html).toContain("内部 GPT"); }); + + it("locked(超会员档)条目灰态禁选,title 说明解锁路径", () => { + const lockedGroups: McCloudModelGroup[] = [ + { + key: "monkeycode-ultra", + label: "旗舰模型", + badge: "旗舰会员免费", + models: [{ id: "u1", model: "monkeycode-ultra/gemini", locked: true }], + }, + ]; + const html = renderToStaticMarkup(); + + expect(html).toContain("旗舰模型"); + expect(html).toContain("disabled"); + expect(html).toContain("opacity:0.55"); + expect(html).toContain('class="menu-item"'); // 不带 hv,悬停无高亮 + expect(html).toContain("当前会员档不可用,升级会员后可用"); + }); }); diff --git a/desktop/ui/src/cloudModelMenu.tsx b/desktop/ui/src/cloudModelMenu.tsx index 2a133799..6f07ee18 100644 --- a/desktop/ui/src/cloudModelMenu.tsx +++ b/desktop/ui/src/cloudModelMenu.tsx @@ -34,12 +34,14 @@ export function CloudModelGroups({ {group.badge && {group.badge}} {group.models.map((model) => ( - // 组头已表达档位,条目不再带档位 tag;hover 兜底完整展示名 + // 组头已表达档位,条目不再带档位 tag;hover 兜底完整展示名。 + // locked(超会员档)灰态禁选,title 说明解锁路径 onPick(model)} /> ))} diff --git a/desktop/ui/src/cloudapi.ts b/desktop/ui/src/cloudapi.ts index ee53cc48..0042c42a 100644 --- a/desktop/ui/src/cloudapi.ts +++ b/desktop/ui/src/cloudapi.ts @@ -396,6 +396,9 @@ export interface CloudControl { const CONTROL_CALL_TIMEOUT_MS = 15_000; +/** 经"休眠唤醒"路径的控制流 call 余量:冷唤醒以分钟计,90s 仍偏紧 */ +export const WAKE_CALL_TIMEOUT_MS = 180_000; + /** 连接云端任务控制流(内核代理)。长生命周期;断线按 stream 同族参数 * 指数退避重连,连续拨号失败/反复断开达上限后放弃自动重连(经 onStatus * 外显"环境离线"),下一次 call() 到来时再重新拨号(懒重连)——此前固定 diff --git a/desktop/ui/src/cloudfiles.tsx b/desktop/ui/src/cloudfiles.tsx index 08070fa4..795590c4 100644 --- a/desktop/ui/src/cloudfiles.tsx +++ b/desktop/ui/src/cloudfiles.tsx @@ -4,7 +4,7 @@ // repo_file_diff(与 web 控制台 task-file-explorer 同一套 kind 与字段), // 差异是 base64 内容解码、entry_mode 判目录、读取上限与唤醒超时余量。 import { useEffect, useRef, useState } from "react"; -import { connectCloudControl, mcFileUpload, type CloudControl } from "./cloudapi"; +import { connectCloudControl, mcFileUpload, WAKE_CALL_TIMEOUT_MS, type CloudControl } from "./cloudapi"; import { readDataURL } from "./cloudUpload"; import { startDownload } from "./downloads"; import { pickSaveFile } from "./host"; @@ -22,8 +22,8 @@ const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 上传上限 10MB(对齐 web 控制 const vmPath = (dir: string, name: string) => "/workspace/" + (dir ? dir + "/" : "") + name; // 控制流 call 默认 15s 超时,但拨号会触发休眠 VM 唤醒(以分钟计): -// 抽屉打开即发的列表/改动调用给足唤醒余量,免得唤醒期间必然超时 -const WAKE_CALL_OPTS = { timeoutMs: 90_000, timeoutMsg: "云端环境可能在唤醒中,响应超时,请稍后重试" }; +// 抽屉内所有调用给足唤醒余量,免得唤醒期间必然超时 +const WAKE_CALL_OPTS = { timeoutMs: WAKE_CALL_TIMEOUT_MS, timeoutMsg: "云端环境可能在唤醒中,响应超时,请稍后重试" }; export function CloudFilesDrawer({ taskId, @@ -81,19 +81,19 @@ export function CloudFilesDrawer({ }, readFile: async (en) => { if ((en.size ?? 0) > MAX_FILE_SIZE) return { plain: `文件较大(${fmtSize(en.size)}),请在网页控制台查看` }; - const r = await ensureCtrl().call<{ content?: string }>("repo_read_file", { - path: en.path, - offset: 0, - length: MAX_FILE_SIZE, - }); + const r = await ensureCtrl().call<{ content?: string }>( + "repo_read_file", + { path: en.path, offset: 0, length: MAX_FILE_SIZE }, + WAKE_CALL_OPTS, + ); return { content: r.content ? b64decode(r.content) : "" }; }, diff: async (path) => { - const r = await ensureCtrl().call<{ diff?: string }>("repo_file_diff", { - path, - unified: true, - context_lines: 20, - }); + const r = await ensureCtrl().call<{ diff?: string }>( + "repo_file_diff", + { path, unified: true, context_lines: 20 }, + WAKE_CALL_OPTS, + ); return r.diff || "(无差异)"; }, diffTransientKind: "plain", diff --git a/desktop/ui/src/cloudtask.tsx b/desktop/ui/src/cloudtask.tsx index dc148742..b610f31a 100644 --- a/desktop/ui/src/cloudtask.tsx +++ b/desktop/ui/src/cloudtask.tsx @@ -18,7 +18,7 @@ import { Composer, QueuedChip, RunningBar } from "./composer"; import { IconCloud, IconFile, IconGlobe, IconMonitor, IconPaperclip, IconStop, IconX } from "./icons"; import { useUpwardMenuHeight } from "./menuPosition"; import { useNativeFileDrop } from "./nativeDrop"; -import { useCloudTask } from "./useCloudTask"; +import { cloudStatusHealthy, useCloudTask } from "./useCloudTask"; const STATUS_LABEL: Record = { pending: { text: "排队中", color: "var(--warn)" }, @@ -367,7 +367,7 @@ export function CloudTaskView({ : running ? "补充说明…运行中发送会排队" : h.commands.length > 0 - ? "继续对话…输入 / 唤起指令,可粘贴或拖入附件" + ? "继续对话…输入 / 使用技能,可粘贴或拖入附件" : "继续对话…粘贴或拖入图片、文件可作为附件" } sendActive={!!h.input.trim()} @@ -471,15 +471,19 @@ export function CloudTaskView({ > - {/* 斜杠指令:点开浏览全部,或在输入框直接敲 / 就地补全 */} + {/* 使用技能(斜杠指令):点开浏览全部,或在输入框直接敲 / 就地补全 */} - - - {h.status} - + {/* 连接状态:健康时隐藏(常驻"已连接云端"没有信息量), + 过渡/异常态才外显(断线重连、消息未送达等) */} + {!cloudStatusHealthy(h.status) && ( + + + {h.status} + + )} {/* 云端模型切换(经控制流 switch_model,保留会话上下文;执行中禁用) */} {/* 包裹层接住 trigger 的 maxWidth:100%(与本地 ModelPicker 同款 @@ -504,6 +508,7 @@ export function CloudTaskView({ groups={h.cloudGroups} selectedId={meta?.model?.id} onPick={(m) => { + if (m.locked) return; setModelOpen(false); void h.switchModel(m.id!); }} diff --git a/desktop/ui/src/commandMenu.test.tsx b/desktop/ui/src/commandMenu.test.tsx index 38c769ec..efe7aa16 100644 --- a/desktop/ui/src/commandMenu.test.tsx +++ b/desktop/ui/src/commandMenu.test.tsx @@ -40,16 +40,18 @@ describe("SlashCommandMenu", () => { expect(html).toContain("无匹配指令"); }); - it("关闭态只渲染触发按钮;有指令时 title 提示可直接敲 /", () => { + it("关闭态只渲染触发按钮;按钮带「使用技能」文字,title 提示可直接敲 /", () => { const html = renderToStaticMarkup(); expect(html).not.toContain("↑↓ 选择"); - expect(html).toContain("斜杠指令(2)"); + // 按钮可见文字(纯 / 图标普通用户看不懂,措辞与移动端对齐) + expect(html).toContain(">使用技能"); + expect(html).toContain("使用技能(2)"); expect(html).toContain("在输入框直接敲 / 也可唤起"); }); - it("Agent 还没上报指令时按钮灰态并说明原因", () => { + it("Agent 还没上报技能时按钮灰态并说明原因", () => { const html = renderToStaticMarkup(); expect(html).toContain("opacity:0.4"); - expect(html).toContain("尚未上报可用指令"); + expect(html).toContain("尚未上报可用技能"); }); }); diff --git a/desktop/ui/src/commandMenu.tsx b/desktop/ui/src/commandMenu.tsx index b10fa44a..5e5be760 100644 --- a/desktop/ui/src/commandMenu.tsx +++ b/desktop/ui/src/commandMenu.tsx @@ -1,7 +1,8 @@ -// 斜杠指令(Agent 上报的 available_commands)选择器:composer 上的 / 按钮 -// + 上弹菜单。移动端是底部「使用技能」面板,桌面遵循自己的交互语言—— +// 技能(Agent 上报的 available_commands,即斜杠指令)选择器:composer 上的 +// 「使用技能」按钮 + 上弹菜单。入口措辞与移动端「使用技能」对齐(纯 / 图标 +// 普通用户看不懂),交互保留桌面双路径—— // 1. 直接在输入框敲 `/` 即就地补全(↑↓ 选择、↩/⇥ 填入、Esc 关掉), -// 2. 不记得指令名时点 composer 左侧的 / 按钮浏览全部。 +// 2. 不记得指令名时点「使用技能」按钮浏览全部。 // 两条路径共用同一份状态(useSlashCommands),菜单只有一个。 import { useCallback, useEffect, useMemo, useState, type KeyboardEvent, type RefObject } from "react"; import { isImeEnter } from "./composer"; @@ -126,7 +127,7 @@ export function useSlashCommands(opts: { }; } -/** composer 左侧的 / 按钮 + 上弹指令菜单(整体自带定位锚点) */ +/** composer 左侧的「使用技能」按钮 + 上弹菜单(整体自带定位锚点) */ export function SlashCommandMenu({ h, count }: { h: SlashCommandsHandle; count: number }) { const { anchorRef, menuMaxHeight } = useUpwardMenuHeight(h.open, 320); const disabled = count === 0; @@ -134,11 +135,22 @@ export function SlashCommandMenu({ h, count }: { h: SlashCommandsHandle; count: {h.open && ( <> diff --git a/desktop/ui/src/newtask.tsx b/desktop/ui/src/newtask.tsx index 8ffba06e..ca95e1e8 100644 --- a/desktop/ui/src/newtask.tsx +++ b/desktop/ui/src/newtask.tsx @@ -818,6 +818,7 @@ export function NewTaskView({ groups={cloudModelGroups} selectedId={cloudModelId} onPick={(cloudModel) => { + if (cloudModel.locked) return; setCloudModelId(cloudModel.id!); if (cloudModel.owner?.type === "public") setCloudHostId(PUBLIC_CLOUD_HOST_ID); setCloudPicker(null); diff --git a/desktop/ui/src/useCloudTask.test.ts b/desktop/ui/src/useCloudTask.test.ts index c814b2b1..92b1c673 100644 --- a/desktop/ui/src/useCloudTask.test.ts +++ b/desktop/ui/src/useCloudTask.test.ts @@ -5,7 +5,7 @@ // 核心刻意不触 React(副作用经 CloudCoreIO 注入),故无需 DOM/renderHook。 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { b64decode } from "./codec"; -import { cloudInitialSource, createCloudTaskCore, type CloudCoreIO } from "./useCloudTask"; +import { cloudInitialSource, cloudStatusHealthy, createCloudTaskCore, type CloudCoreIO } from "./useCloudTask"; import type { CloudTaskDetail } from "./types"; // ---- 假 Tauri 壳:cloud_ws_open 按脚本决定成败;事件按 pipe 精确投递 ---- @@ -134,6 +134,20 @@ describe("云端任务首屏数据源", () => { }); }); +describe("连接状态行健康判定(composer 健康时隐藏指示)", () => { + it("健康文案命中白名单,过渡/异常态判为需外显", () => { + expect(cloudStatusHealthy("已连接云端")).toBe(true); + expect(cloudStatusHealthy("已就绪,可继续对话")).toBe(true); + expect(cloudStatusHealthy("本轮已结束,可继续对话")).toBe(true); + expect(cloudStatusHealthy("已结束,只读回放")).toBe(true); + + expect(cloudStatusHealthy("加载中…")).toBe(false); + expect(cloudStatusHealthy("连接云端…")).toBe(false); + expect(cloudStatusHealthy("⚠ 云端连接断开(x),2 秒后自动重连…")).toBe(false); + expect(cloudStatusHealthy("消息未送达,已重新排队")).toBe(false); + }); +}); + describe("云端投递状态机:排队与自动投递", () => { it("启动中直发被拒 → 入队;attach 就绪后自动投递", async () => { // 环境未就绪(pending):手动发送不看本地推断,直接建 mode=new 交服务端 @@ -278,6 +292,40 @@ describe("云端投递状态机:排队与自动投递", () => { expect(sentUserInputs()).toEqual(["唤醒后见"]); }); + it("唤醒序列 hibernated → offline → online(后端误判中间态):仍视为唤醒完成并投递", async () => { + // 后端会把唤醒中的 VM 短暂误判为 offline:offline 帧把 hibernated 镜像 + // 清掉,若只认镜像,随后的 online 不触发唤醒完成 → 队列永久卡死 + opens = [false, false, false, true]; + const { core, out } = makeCore(); + core.handleInfo({ id: "task-1", virtualmachine: { status: "hibernated" } } as CloudTaskDetail); + core.noteHibernated(true); + core.send("穿过误判"); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2100); + await vi.advanceTimersByTimeAsync(2100); + expect(out.queued).toBe("穿过误判"); // 连败暂停,压在队里 + // 误判中间态:详情帧闪过 offline,hook 每帧镜像随之清掉 hibernated + //(发送失败路径自身会 bump epoch,故用相对计数) + const bumpsBefore = out.epochBumps; + core.handleInfo({ id: "task-1", virtualmachine: { status: "offline" } } as CloudTaskDetail); + core.noteHibernated(false); + expect(out.epochBumps).toBe(bumpsBefore); + // 真正唤醒完成:按"上次非 online → 本次 online"转变触发 + core.handleInfo({ id: "task-1", virtualmachine: { status: "online" } } as CloudTaskDetail); + expect(out.epochBumps).toBe(bumpsBefore + 1); + await vi.advanceTimersByTimeAsync(150); + expect(out.queued).toBe(""); + expect(sentUserInputs()).toEqual(["穿过误判"]); + }); + + it("首帧即 online 与 online → online:不触发 attach 重建(不随轮询抖)", () => { + const { core, out } = makeCore(); + // 健康任务首帧就是 online:误 bump 会让 attach 拆建、整轮重放 + core.handleInfo({ id: "task-1", virtualmachine: { status: "online" } } as CloudTaskDetail); + core.handleInfo({ id: "task-1", virtualmachine: { status: "online" } } as CloudTaskDetail); + expect(out.epochBumps).toBe(0); + }); + it("任务结束还压着队列 → 外显提醒并清空,不静默丢", async () => { opens = [true]; const { core, out } = makeCore(); diff --git a/desktop/ui/src/useCloudTask.ts b/desktop/ui/src/useCloudTask.ts index aef027a9..1622b25d 100644 --- a/desktop/ui/src/useCloudTask.ts +++ b/desktop/ui/src/useCloudTask.ts @@ -22,6 +22,7 @@ import { mcTaskOptions, mcTaskRounds, mcTaskStop, + WAKE_CALL_TIMEOUT_MS, type CloudConn, type CloudUserInput, } from "./cloudapi"; @@ -45,6 +46,12 @@ export function cloudInitialSource(status: string): "attach" | "rounds" | "pendi return "pending"; } +/** 连接状态行的健康文案白名单:健康时 composer 不渲染状态指示(混在 + * 工具栏里是常驻噪音),异常/过渡态才外显。用白名单而非异常名单: + * 未来新增的异常文案默认可见,不会被静默吞掉。 */ +const HEALTHY_STATUS = new Set(["已连接云端", "已就绪,可继续对话", "本轮已结束,可继续对话", "已结束,只读回放"]); +export const cloudStatusHealthy = (status: string) => HEALTHY_STATUS.has(status); + // ==================== 状态机核心(非 React,可单测) ==================== /** 核心对宿主(hook / 测试)的输出口:React 状态回写与跨模块副作用全部 @@ -101,6 +108,7 @@ export function createCloudTaskCore( let taskStatus = "pending"; // 详情状态的渲染期镜像(原 statusRef) // VM 状态:云环境空闲会休眠(hibernated);休眠期间发送入队,唤醒后自动投递 let hibernated = false; + let lastVmStatus: string | null = null; // 上次详情帧的 VM 状态(null=尚未观测) // attach 已收束/放弃(onIdle):不再自动重建;发消息(mode=new)或唤醒重新武装 let attachIdle = false; let sendFails = 0; // 连续投递失败计数(超限暂停自动重试) @@ -317,17 +325,24 @@ export function createCloudTaskCore( hibernated = h; }, - /** 详情刷新回调:VM 唤醒完成时,休眠期间压着的排队消息可以投递了; - * attach 也重新武装(唤醒 = 新的活动窗口,给一次重建机会——按转变 - * 触发,不随轮询抖) */ + /** 详情刷新回调:VM 回到 online 视为唤醒完成,休眠期间压着的排队消息 + * 可以投递了;attach 也重新武装(唤醒 = 新的活动窗口)。按"上次非 + * online → 本次 online"的转变检测而非只看 hibernated 镜像——后端会把 + * 唤醒中的 VM 短暂误判为 offline(hibernated → offline → online), + * offline 帧会清掉镜像,只看镜像会让排队消息永久卡死、attach 不重建。 + * lastVmStatus !== null 守卫:首帧即 online 的健康任务不触发(否则 + * attach 拆建一次,服务端把当前轮整轮重放);online → online 不随轮询抖。 */ handleInfo(info: CloudTaskDetail) { - if (info.virtualmachine?.status === "online" && hibernated) { - hibernated = false; - attachIdle = false; - sendFails = 0; - io.bumpAttachEpoch(); - setTimeout(trySendQueued, 100); - } + const vm = info.virtualmachine?.status ?? ""; + const cameOnline = vm === "online" && lastVmStatus !== null && lastVmStatus !== "online"; + const wake = vm === "online" && (cameOnline || hibernated); + lastVmStatus = vm; + if (!wake) return; + hibernated = false; + attachIdle = false; + sendFails = 0; + io.bumpAttachEpoch(); + setTimeout(trySendQueued, 100); }, /** attach effect 主体:守卫通过则建连并返回 true(effect 据此注册 cleanup)。 */ @@ -356,6 +371,7 @@ export function createCloudTaskCore( live = []; attachIdle = false; sendFails = 0; + lastVmStatus = null; }, /** REST 播种历史(进入任务时已完成轮次) */ @@ -573,6 +589,8 @@ export function useCloudTask( // 状态轮询:pending/休眠唤醒中 3s(盯状态翻转),processing 10s(刷新元数据) const vmWaking = taskStatus === "processing" && vmStatus === "hibernated"; + const vmWakingRef = useRef(false); + vmWakingRef.current = vmWaking; // 渲染期镜像(控制流回调长期持有,不能闭包渲染值) useEffect(() => { if (ended) return; const fast = taskStatus === "pending" || vmWaking; @@ -587,8 +605,15 @@ export function useCloudTask( useEffect(() => { if (ended || !vmId) return; // 控制流放弃自动重连(连不上/反复断开)时外显;恢复(ok=true)清掉。 - // 之后任何经它的操作(切模型/端口列表)会触发懒重连 - const ctrl = connectCloudControl(id, { onStatus: (text, ok) => setErr(ok ? "" : text) }); + // 之后任何经它的操作(切模型/端口列表)会触发懒重连。 + // 唤醒期间拨号必然连败,"环境离线"与标题栏"环境唤醒中"同屏矛盾:压掉, + // 唤醒完成后由下方 effect 复活通道 + const ctrl = connectCloudControl(id, { + onStatus: (text, ok) => { + if (ok) setErr(""); + else if (!vmWakingRef.current) setErr(text); + }, + }); ctrlRef.current = ctrl; // 连接触发唤醒后,尽快让轮询看到状态翻转 const t = setTimeout(() => void refreshInfo(), 1500); @@ -599,6 +624,18 @@ export function useCloudTask( }; }, [id, ended, vmId, refreshInfo]); + // 唤醒完成:控制通道若在唤醒期间连败放弃,用一次轻量 call 触发懒重连 + // (cloudapi 在 call() 入口武装 offline 懒重连),恢复保活;失败静默。 + // 刻意不把 vmWaking 挂进上方 ctrl effect 依赖:拆建连接会 reject 唤醒中 + // 在途的 switch_model 等长等待 call + const prevWakingRef = useRef(false); + useEffect(() => { + if (prevWakingRef.current && !vmWaking && ctrlRef.current) { + void ctrlRef.current.call("port_forward_list", {}, { timeoutMs: WAKE_CALL_TIMEOUT_MS }).catch(() => undefined); + } + prevWakingRef.current = vmWaking; + }, [vmWaking]); + // 运行中:WS attach 跟看(内核代理带 monkeycode 会话拨云端)。 // 依赖刻意不含 vmWaking:vmStatus 由轮询刷新,抖动会反复拆建连接, // 每次重建把 connectCloudTask 内部的重连上限清零 → 永久"断开重连"。 @@ -723,6 +760,8 @@ export function useCloudTask( }; const switchModel = async (modelId: string) => { if (switching || modelId === meta?.model?.id) return; + // locked(超会员档)条目只展示不可切:菜单层已禁选,这里兜底防旁路 + if (cloudGroups?.some((g) => g.models.some((m) => m.id === modelId && m.locked))) return; setSwitching(true); setErr(""); // 优先复用常驻控制连接;不在(结束态等)才临时建一条 @@ -734,7 +773,7 @@ export function useCloudTask( await ctrl.call( "switch_model", { model_id: modelId, load_session: true }, - { timeoutMs: 90_000, timeoutMsg: "操作超时——云端环境可能在唤醒中,切换可能已生效" }, + { timeoutMs: WAKE_CALL_TIMEOUT_MS, timeoutMsg: "操作超时——云端环境可能在唤醒中,切换可能已生效" }, ); } catch (e) { setErr("切换模型失败: " + (e instanceof Error ? e.message : String(e))); @@ -753,7 +792,8 @@ export function useCloudTask( const shared = ctrlRef.current; const ctrl = shared ?? connectCloudControl(id); ctrl - .call<{ ports?: PortInfo[] }>("port_forward_list") + // 唤醒路径同样给足余量:默认 15s 在唤醒期间必超时,菜单会误显"没有开放的端口" + .call<{ ports?: PortInfo[] }>("port_forward_list", {}, { timeoutMs: WAKE_CALL_TIMEOUT_MS, timeoutMsg: "云端环境可能在唤醒中,端口检测超时" }) .then((r) => setPorts(r.ports ?? [])) .catch(() => setPorts([])) .finally(() => {