Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions desktop/ui/src/cloud-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("云端创建选项", () => {
expect(groups.map((group) => group.label)).toEqual([
"基础模型",
"专业模型",
"旗舰模型",
"付费模型",
"我的模型",
"研发团队",
Expand All @@ -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);
Expand Down
13 changes: 9 additions & 4 deletions desktop/ui/src/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions desktop/ui/src/cloudModelMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CloudModelGroups groups={lockedGroups} onPick={vi.fn()} />);

expect(html).toContain("旗舰模型");
expect(html).toContain("disabled");
expect(html).toContain("opacity:0.55");
expect(html).toContain('class="menu-item"'); // 不带 hv,悬停无高亮
expect(html).toContain("当前会员档不可用,升级会员后可用");
});
});
6 changes: 4 additions & 2 deletions desktop/ui/src/cloudModelMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ export function CloudModelGroups({
{group.badge && <span style={{ flex: "none", fontSize: 9.5, color: "var(--t6)" }}>{group.badge}</span>}
</span>
{group.models.map((model) => (
// 组头已表达档位,条目不再带档位 tag;hover 兜底完整展示名
// 组头已表达档位,条目不再带档位 tag;hover 兜底完整展示名。
// locked(超会员档)灰态禁选,title 说明解锁路径
<ModelMenuItem
key={model.id}
label={groupedCloudModelLabel(model)}
title={cloudModelLabel(model)}
title={model.locked ? `${cloudModelLabel(model)} · 当前会员档不可用,升级会员后可用` : cloudModelLabel(model)}
selected={model.id === selectedId}
disabled={model.locked}
onClick={() => onPick(model)}
/>
))}
Expand Down
3 changes: 3 additions & 0 deletions desktop/ui/src/cloudapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() 到来时再重新拨号(懒重连)——此前固定
Expand Down
26 changes: 13 additions & 13 deletions desktop/ui/src/cloudfiles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 15 additions & 10 deletions desktop/ui/src/cloudtask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { text: string; color: string }> = {
pending: { text: "排队中", color: "var(--warn)" },
Expand Down Expand Up @@ -367,7 +367,7 @@ export function CloudTaskView({
: running
? "补充说明…运行中发送会排队"
: h.commands.length > 0
? "继续对话…输入 / 唤起指令,可粘贴或拖入附件"
? "继续对话…输入 / 使用技能,可粘贴或拖入附件"
: "继续对话…粘贴或拖入图片、文件可作为附件"
}
sendActive={!!h.input.trim()}
Expand Down Expand Up @@ -471,15 +471,19 @@ export function CloudTaskView({
>
<IconPaperclip size={13} color="var(--t3)" />
</button>
{/* 斜杠指令:点开浏览全部,或在输入框直接敲 / 就地补全 */}
{/* 使用技能(斜杠指令):点开浏览全部,或在输入框直接敲 / 就地补全 */}
<SlashCommandMenu h={slash} count={h.commands.length} />
<span
title={`${h.status} · 任务运行在云端服务器,关掉客户端也会继续`}
style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: "var(--t5)", minWidth: 0 }}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: h.connected ? "var(--ok)" : "var(--t6)", flex: "none" }} />
<span className="ellipsis">{h.status}</span>
</span>
{/* 连接状态:健康时隐藏(常驻"已连接云端"没有信息量),
过渡/异常态才外显(断线重连、消息未送达等) */}
{!cloudStatusHealthy(h.status) && (
<span
title={`${h.status} · 任务运行在云端服务器,关掉客户端也会继续`}
style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: "var(--t5)", minWidth: 0 }}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: h.connected ? "var(--ok)" : "var(--t6)", flex: "none" }} />
<span className="ellipsis">{h.status}</span>
</span>
)}
<span style={{ flex: 1 }} />
{/* 云端模型切换(经控制流 switch_model,保留会话上下文;执行中禁用) */}
{/* 包裹层接住 trigger 的 maxWidth:100%(与本地 ModelPicker 同款
Expand All @@ -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!);
}}
Expand Down
10 changes: 6 additions & 4 deletions desktop/ui/src/commandMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,18 @@ describe("SlashCommandMenu", () => {
expect(html).toContain("无匹配指令");
});

it("关闭态只渲染触发按钮;有指令时 title 提示可直接敲 /", () => {
it("关闭态只渲染触发按钮;按钮带「使用技能」文字,title 提示可直接敲 /", () => {
const html = renderToStaticMarkup(<SlashCommandMenu h={handle({ open: false, list })} count={2} />);
expect(html).not.toContain("↑↓ 选择");
expect(html).toContain("斜杠指令(2)");
// 按钮可见文字(纯 / 图标普通用户看不懂,措辞与移动端对齐)
expect(html).toContain(">使用技能</button>");
expect(html).toContain("使用技能(2)");
expect(html).toContain("在输入框直接敲 / 也可唤起");
});

it("Agent 还没上报指令时按钮灰态并说明原因", () => {
it("Agent 还没上报技能时按钮灰态并说明原因", () => {
const html = renderToStaticMarkup(<SlashCommandMenu h={handle({ open: false })} count={0} />);
expect(html).toContain("opacity:0.4");
expect(html).toContain("尚未上报可用指令");
expect(html).toContain("尚未上报可用技能");
});
});
26 changes: 19 additions & 7 deletions desktop/ui/src/commandMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -126,19 +127,30 @@ export function useSlashCommands(opts: {
};
}

/** composer 左侧的 / 按钮 + 上弹指令菜单(整体自带定位锚点) */
/** composer 左侧的「使用技能」按钮 + 上弹菜单(整体自带定位锚点) */
export function SlashCommandMenu({ h, count }: { h: SlashCommandsHandle; count: number }) {
const { anchorRef, menuMaxHeight } = useUpwardMenuHeight<HTMLSpanElement>(h.open, 320);
const disabled = count === 0;
return (
<span ref={anchorRef} style={{ position: "relative", display: "flex", flex: "none" }}>
<button
className="hv2 icon-btn"
title={disabled ? "Agent 尚未上报可用指令(环境就绪后自动同步)" : `斜杠指令(${count})· 在输入框直接敲 / 也可唤起`}
title={disabled ? "Agent 尚未上报可用技能(环境就绪后自动同步)" : `使用技能(${count})· 在输入框直接敲 / 也可唤起`}
onClick={h.toggle}
style={{ width: 24, height: 24, borderRadius: 7, background: h.open ? "var(--hov)" : "transparent", opacity: disabled ? 0.4 : 1 }}
style={{
height: 24,
padding: "0 7px",
gap: 4,
borderRadius: 7,
background: h.open ? "var(--hov)" : "transparent",
fontSize: 11.5,
fontWeight: 500,
color: "var(--t3)",
opacity: disabled ? 0.4 : 1,
}}
>
<IconSlash size={13} color="var(--t3)" />
<IconSlash size={12} color="var(--t3)" />
使用技能
</button>
{h.open && (
<>
Expand Down
1 change: 1 addition & 0 deletions desktop/ui/src/newtask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
50 changes: 49 additions & 1 deletion desktop/ui/src/useCloudTask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 精确投递 ----
Expand Down Expand Up @@ -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 交服务端
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading