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(() => {