chore: make non-i18n code English-only (#117)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -147,12 +147,14 @@ describe("StreamRenderer", () => {
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '"description":"列出当前目录的文件"}',
|
||||
arguments: '"description":"List files in the current directory"}',
|
||||
toolCallId: "c9",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c9" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c9] exec_command <- 列出当前目录的文件 ($ ls -la)\n");
|
||||
expect(stripAnsi(text())).toBe(
|
||||
"[tool-c9] exec_command <- List files in the current directory ($ ls -la)\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("streams the command live when the schema has no description argument", () => {
|
||||
|
||||
@@ -129,10 +129,10 @@ describe("describe_image (the text-only-model variant of read_image)", () => {
|
||||
|
||||
it("image + custom prompt is sent to the vision model in a single shot, its text comes back, the result carries no images", async () => {
|
||||
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
||||
const { llm, calls } = fakeLLM("图里是一只企鹅。");
|
||||
const { llm, calls } = fakeLLM("The image shows a penguin.");
|
||||
const describer: VisionDescriberService = { modelId: "vis-1", createLLM: () => llm };
|
||||
const { messages, result, text } = await run(
|
||||
{ source: "a.png", prompt: "图里是什么动物?" },
|
||||
{ source: "a.png", prompt: "What animal is in the image?" },
|
||||
tmp,
|
||||
describer,
|
||||
);
|
||||
@@ -143,17 +143,17 @@ describe("describe_image (the text-only-model variant of read_image)", () => {
|
||||
(m) => m.payload as { type: string; text?: string; image_url?: string },
|
||||
);
|
||||
expect(payloads[0]!.type).toBe("text");
|
||||
expect(payloads[0]!.text).toBe("图里是什么动物?");
|
||||
expect(payloads[0]!.text).toBe("What animal is in the image?");
|
||||
expect(payloads[1]!.type).toBe("image_url");
|
||||
expect(payloads[1]!.image_url).toBe(`data:image/png;base64,${PNG_1X1.toString("base64")}`);
|
||||
|
||||
expect(text).toContain("described by vis-1");
|
||||
expect(text).toContain("图里是一只企鹅。");
|
||||
expect(text).toContain("The image shows a penguin.");
|
||||
// Streaming forward: the header line and description deltas are emitted as separate chunks (not buffered as a whole), and the complete text is not forwarded again.
|
||||
const outputs = messages.map((m) => (m.payload as { output?: string }).output ?? "");
|
||||
expect(outputs.length).toBeGreaterThanOrEqual(3); // header + >=2 description delta chunks
|
||||
expect(outputs[0]).toContain("described by vis-1");
|
||||
expect(outputs.slice(1).join("")).toBe("图里是一只企鹅。");
|
||||
expect(outputs.slice(1).join("")).toBe("The image shows a penguin.");
|
||||
// Text-based description: the result carries no images (images never enter session history).
|
||||
expect(result?.images).toBeUndefined();
|
||||
expect(result?.stopReason).toBeUndefined(); // defaults to completed
|
||||
@@ -200,7 +200,7 @@ describe("describe_image (the text-only-model variant of read_image)", () => {
|
||||
|
||||
it("Environment assembles the describe_image entry with the delegated-description implementation; definition matches the config", async () => {
|
||||
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
||||
const { llm } = fakeLLM("代读结果");
|
||||
const { llm } = fakeLLM("delegated description result");
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: {
|
||||
@@ -235,7 +235,7 @@ describe("describe_image (the text-only-model variant of read_image)", () => {
|
||||
};
|
||||
expect(complete.type).toBe("tool_call_output");
|
||||
expect(complete.stop_reason).toBe("completed");
|
||||
expect(complete.output).toContain("代读结果");
|
||||
expect(complete.output).toContain("delegated description result");
|
||||
expect(complete.images).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,19 +79,19 @@ describe("stripConversationMarkers (whole-message title cleaning)", () => {
|
||||
// The skill-invocation block that wraps a first user message must not reach the title.
|
||||
expect(
|
||||
stripConversationMarkers(
|
||||
"[use_skills]\nskills: penguin-sdk, web-design\n[/use_skills]\n做一个 RAG 应用",
|
||||
"[use_skills]\nskills: penguin-sdk, web-design\n[/use_skills]\nBuild a RAG app",
|
||||
),
|
||||
).toBe("做一个 RAG 应用");
|
||||
).toBe("Build a RAG app");
|
||||
// Handoff and scheduled-task markers are stripped too; ordinary bracketed text stays.
|
||||
expect(stripConversationMarkers("[handoff_from]data_analyst[/handoff_from]继续分析")).toBe(
|
||||
"继续分析",
|
||||
);
|
||||
expect(
|
||||
stripConversationMarkers("[handoff_from]data_analyst[/handoff_from]continue the analysis"),
|
||||
).toBe("continue the analysis");
|
||||
// The /model switch origin block (the new session's first message) must not leak into the title either.
|
||||
expect(
|
||||
stripConversationMarkers(
|
||||
"[model_switch_from]\nsession: session-01\ntrace: /t/x_001.jsonl\n[/model_switch_from]\n继续这个任务",
|
||||
"[model_switch_from]\nsession: session-01\ntrace: /t/x_001.jsonl\n[/model_switch_from]\ncontinue this task",
|
||||
),
|
||||
).toBe("继续这个任务");
|
||||
).toBe("continue this task");
|
||||
expect(stripConversationMarkers("render a <div> element")).toBe("render a <div> element");
|
||||
expect(stripConversationMarkers("check the [config] section")).toBe(
|
||||
"check the [config] section",
|
||||
@@ -100,14 +100,18 @@ describe("stripConversationMarkers (whole-message title cleaning)", () => {
|
||||
|
||||
it("the old angle-bracket marker form is still stripped (material from old Traces)", () => {
|
||||
expect(
|
||||
stripConversationMarkers("<use_skills>\nskills: web-design\n</use_skills>\n做一个落地页"),
|
||||
).toBe("做一个落地页");
|
||||
expect(stripConversationMarkers("<handoff_from>data_analyst</handoff_from>继续分析")).toBe(
|
||||
"继续分析",
|
||||
);
|
||||
stripConversationMarkers(
|
||||
"<use_skills>\nskills: web-design\n</use_skills>\nBuild a landing page",
|
||||
),
|
||||
).toBe("Build a landing page");
|
||||
expect(
|
||||
stripConversationMarkers("<model_switch_from>session: s1</model_switch_from>继续这个任务"),
|
||||
).toBe("继续这个任务");
|
||||
stripConversationMarkers("<handoff_from>data_analyst</handoff_from>continue the analysis"),
|
||||
).toBe("continue the analysis");
|
||||
expect(
|
||||
stripConversationMarkers(
|
||||
"<model_switch_from>session: s1</model_switch_from>continue this task",
|
||||
),
|
||||
).toBe("continue this task");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -64,18 +64,18 @@ describe("session-title", () => {
|
||||
const result = await generateTitleWithLLM(
|
||||
fakeLLM(
|
||||
[
|
||||
thinkingMessage("想一下"), // thinking does not count
|
||||
assistantText("「Tailwind 主题配置」。"),
|
||||
thinkingMessage("thinking"), // thinking does not count
|
||||
assistantText("「Tailwind theme setup」。"),
|
||||
tokenUsage(emptyTokenCounts(), { cache_read: 1, cache_write: 2, output: 3, total: 6 }),
|
||||
],
|
||||
{ status: "completed" },
|
||||
seen,
|
||||
),
|
||||
{ userText: "解释 @theme", assistantText: "好的……" },
|
||||
{ userText: "explain @theme", assistantText: "sure thing." },
|
||||
);
|
||||
expect(result.title).toBe("Tailwind 主题配置");
|
||||
expect(result.title).toBe("Tailwind theme setup");
|
||||
expect(result.usage).toEqual({ cache_read: 1, cache_write: 2, output: 3, total: 6 });
|
||||
expect(seen[0]).toBe(buildTitlePrompt("解释 @theme", "好的……"));
|
||||
expect(seen[0]).toBe(buildTitlePrompt("explain @theme", "sure thing."));
|
||||
expect(seen[0]).toContain("SAME language");
|
||||
});
|
||||
|
||||
@@ -105,23 +105,23 @@ describe("session-title", () => {
|
||||
it("still generates with empty assistant material (tool-only turn): uses only the user request, prompt omits the assistant section", async () => {
|
||||
const seen: string[] = [];
|
||||
const result = await generateTitleWithLLM(
|
||||
fakeLLM([assistantText("配置 Tailwind 主题")], { status: "completed" }, seen),
|
||||
{ userText: "帮我配置 @theme", assistantText: "" },
|
||||
fakeLLM([assistantText("Configure the Tailwind theme")], { status: "completed" }, seen),
|
||||
{ userText: "help me configure @theme", assistantText: "" },
|
||||
);
|
||||
expect(result.title).toBe("配置 Tailwind 主题");
|
||||
expect(seen[0]).toBe(buildTitlePrompt("帮我配置 @theme", ""));
|
||||
expect(result.title).toBe("Configure the Tailwind theme");
|
||||
expect(seen[0]).toBe(buildTitlePrompt("help me configure @theme", ""));
|
||||
expect(seen[0]).not.toContain("[Assistant]");
|
||||
});
|
||||
|
||||
it("sanitizeTitle: strips quotes/punctuation to a fixed point, collapses whitespace, truncates overlong input, returns null for empty", () => {
|
||||
expect(sanitizeTitle("“ 构建配置 说明 。”")).toBe("构建配置 说明");
|
||||
expect(sanitizeTitle("『标题』!")).toBe("标题");
|
||||
expect(sanitizeTitle("“ Build config notes 。”")).toBe("Build config notes");
|
||||
expect(sanitizeTitle("『Title』!")).toBe("Title");
|
||||
expect(sanitizeTitle(" \n ")).toBeNull();
|
||||
expect(sanitizeTitle("x".repeat(50))).toHaveLength(30);
|
||||
// A leaked [use_skills] block is stripped from the model output.
|
||||
expect(sanitizeTitle("[use_skills]\nskills: web-design\n[/use_skills]\n构建落地页")).toBe(
|
||||
"构建落地页",
|
||||
);
|
||||
expect(
|
||||
sanitizeTitle("[use_skills]\nskills: web-design\n[/use_skills]\nBuild a landing page"),
|
||||
).toBe("Build a landing page");
|
||||
});
|
||||
|
||||
it("Session.generateTitle: sends via createBareLLM; returns null when no factory is provided", async () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function parseAuthors(raw: string | undefined): string[] {
|
||||
return authors.length > 0 ? authors : [DEFAULT_AUTHOR];
|
||||
}
|
||||
|
||||
/** Join authors for display: 顿号 in Chinese, comma otherwise. */
|
||||
/** Join authors for display: an ideographic comma in Chinese, an ASCII comma otherwise. */
|
||||
export function formatAuthors(authors: string[], locale: Locale): string {
|
||||
return authors.join(locale === "zh" ? "、" : ", ");
|
||||
}
|
||||
@@ -82,7 +82,8 @@ export function comparePosts(
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD post date for display ("July 20, 2026" / "2026年7月20日").
|
||||
* Format a YYYY-MM-DD post date for display (e.g. "July 20, 2026" in English, the
|
||||
* year-month-day form in Chinese).
|
||||
* Parsed as UTC and formatted in UTC so the calendar day never shifts with the
|
||||
* viewer's timezone; unexpected input falls back to the raw string.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Hero: enlarged logo + product name, the slogan inside the pill badge, then the
|
||||
* one-line headline whose rotating word crossfades through a gaussian blur
|
||||
* (Desktop <-> Server / 桌面 <-> 服务器), three keywords, the install one-liner
|
||||
* (Desktop <-> Server, localized per dictionary), three keywords, the install one-liner
|
||||
* and stats. The rotating word is a stacked inline-grid so line width never jumps.
|
||||
*/
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("formatPostDate", () => {
|
||||
expect(formatPostDate("2026-07-20", "en")).toBe("July 20, 2026");
|
||||
});
|
||||
|
||||
it("formats zh dates as 年月日", () => {
|
||||
it("formats zh dates as year-month-day", () => {
|
||||
expect(formatPostDate("2026-06-18", "zh")).toBe("2026年6月18日");
|
||||
expect(formatPostDate("2026-07-20", "zh")).toBe("2026年7月20日");
|
||||
});
|
||||
@@ -68,9 +68,9 @@ describe("parseAuthors / formatAuthors", () => {
|
||||
expect(parseAuthors(" ")).toEqual([DEFAULT_AUTHOR]);
|
||||
});
|
||||
|
||||
it("joins with a comma in English and 顿号 in Chinese", () => {
|
||||
it("joins with a comma in English and an ideographic comma in Chinese", () => {
|
||||
expect(formatAuthors(["A", "B"], "en")).toBe("A, B");
|
||||
expect(formatAuthors(["甲", "乙"], "zh")).toBe("甲、乙");
|
||||
expect(formatAuthors(["A", "B"], "zh")).toBe("A、B");
|
||||
expect(formatAuthors(["A"], "en")).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("models preset & catalog enrichment", () => {
|
||||
const { cookie } = await provisionUser(t.app, "alice");
|
||||
api = apiClient(t.app, cookie);
|
||||
const created = (await (
|
||||
await api.post("/api/projects", { projectId: "alice-preset", name: "预置项目" })
|
||||
await api.post("/api/projects", { projectId: "alice-preset", name: "Preset project" })
|
||||
).json()) as ProjectCreateResponse;
|
||||
projectId = created.project.projectId;
|
||||
});
|
||||
@@ -53,7 +53,7 @@ describe("models preset & catalog enrichment", () => {
|
||||
await t.cleanup();
|
||||
});
|
||||
|
||||
it("credential 内联单文件:PUT 的 apiKey 落 .project_config.toml(0600),GET 只回掩码", async () => {
|
||||
it("credentials are inlined in one file: the apiKey from PUT lands in .project_config.toml (0600), GET returns only a mask", async () => {
|
||||
const put = await api.put(url(), {
|
||||
defaultModel: { provider: "custom", modelId: "m-inline" },
|
||||
models: [
|
||||
@@ -93,7 +93,7 @@ describe("models preset & catalog enrichment", () => {
|
||||
await expect(readFile(path.join(projectDir, "project_config.toml"), "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("新建 Project 即预置全部内置模型(provider 与 model_id 分列 + 目录信息)", async () => {
|
||||
it("a new Project is preset with every built-in model (provider and model_id as separate fields + catalog info)", async () => {
|
||||
const res = await api.get(url());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as ModelsResponse;
|
||||
@@ -125,7 +125,7 @@ describe("models preset & catalog enrichment", () => {
|
||||
expect(mimo.credential?.apiKeyMasked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("PUT 自定义模型:持久化 vision 标注;openai 协议给 OPENAI_API_KEY 兜底;provider 必填", async () => {
|
||||
it("PUT a custom model: the vision flag persists; the openai protocol falls back to OPENAI_API_KEY; provider is required", async () => {
|
||||
const put = await api.put(url(), {
|
||||
defaultModel: { provider: "custom", modelId: "my-model" },
|
||||
models: [
|
||||
@@ -167,7 +167,7 @@ describe("models preset & catalog enrichment", () => {
|
||||
expect(noProvider.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PUT maxTokens:落盘为 max_tokens 并经 GET 回读;整表省略即清除;0/负数/非数字 400", async () => {
|
||||
it("PUT maxTokens: persisted as max_tokens and read back through GET; omitting it table-wide clears it; 0 / negative / non-numeric 400", async () => {
|
||||
const put = await api.put(url(), {
|
||||
models: [
|
||||
{ provider: "custom", modelId: "local-qwen", clientType: "openai", maxTokens: 8000 },
|
||||
@@ -204,7 +204,7 @@ describe("models preset & catalog enrichment", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("同名 model_id 可在不同 provider 下并存(成对键,互不覆盖)", async () => {
|
||||
it("the same model_id can coexist under different providers (paired keys, neither overwrites the other)", async () => {
|
||||
const put = await api.put(url(), {
|
||||
models: [
|
||||
{ provider: "moonshot", modelId: "kimi-k2.6", apiKey: "sk-official-aaaa1111" },
|
||||
@@ -231,7 +231,7 @@ describe("models preset & catalog enrichment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("default_project 预置", () => {
|
||||
describe("default_project presets", () => {
|
||||
let t: TestApp;
|
||||
let prevKey: string | undefined;
|
||||
|
||||
@@ -247,7 +247,7 @@ describe("default_project 预置", () => {
|
||||
await t.cleanup();
|
||||
});
|
||||
|
||||
it("种子 admin 纳管 default_project 时补齐预置模型与默认模型(此前无 .project_config.toml)", async () => {
|
||||
it("when the seeded admin adopts default_project, the preset models and the default model are filled in (no prior .project_config.toml)", async () => {
|
||||
// The default_project shared by admin seeding and the CLI (the dir already exists, so writeInitialConfig is skipped).
|
||||
t = await createTestApp();
|
||||
const { cookie } = await loginAdmin(t.app);
|
||||
@@ -269,7 +269,7 @@ describe("default_project 预置", () => {
|
||||
expect(session.modelId).toBe("deepseek-v4-pro");
|
||||
});
|
||||
|
||||
it("已配置过模型的 default_project 原样保留(不覆盖 CLI 既有配置)", async () => {
|
||||
it("a default_project that already has models configured is left untouched (existing CLI config is not overwritten)", async () => {
|
||||
// First have the "CLI" write a config with a single custom model, then admin seeding adopts it.
|
||||
t = await createTestApp({
|
||||
beforeSeed: async (root) => {
|
||||
@@ -290,7 +290,7 @@ describe("default_project 预置", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("模型引用改键与连通性测试", () => {
|
||||
describe("model-reference rekeying and the connectivity test", () => {
|
||||
let t: TestApp;
|
||||
let api: ReturnType<typeof apiClient>;
|
||||
let projectId: string;
|
||||
@@ -302,7 +302,7 @@ describe("模型引用改键与连通性测试", () => {
|
||||
const { cookie } = await provisionUser(t.app, "carol");
|
||||
api = apiClient(t.app, cookie);
|
||||
const created = (await (
|
||||
await api.post("/api/projects", { projectId: "carol-rename", name: "改名项目" })
|
||||
await api.post("/api/projects", { projectId: "carol-rename", name: "Rename project" })
|
||||
).json()) as ProjectCreateResponse;
|
||||
projectId = created.project.projectId;
|
||||
});
|
||||
@@ -310,7 +310,7 @@ describe("模型引用改键与连通性测试", () => {
|
||||
await t.cleanup();
|
||||
});
|
||||
|
||||
it("renamedFrom 迁移 credential 与配置,默认/视觉模型指针跟随改键", async () => {
|
||||
it("renamedFrom migrates the credential and the config; the default / vision model pointers follow the rekey", async () => {
|
||||
await api.put(url(), {
|
||||
defaultModel: { provider: "custom", modelId: "old-id" },
|
||||
visionModel: { provider: "custom", modelId: "old-id" },
|
||||
@@ -347,7 +347,7 @@ describe("模型引用改键与连通性测试", () => {
|
||||
expect(again.defaultModel).toEqual({ provider: "custom", modelId: "new-id" });
|
||||
});
|
||||
|
||||
it("分组变更也是改键:credential 随迁;envKey 按 client 解析、不随分组(PRN-021)", async () => {
|
||||
it("a group change is a rekey too: the credential migrates with it; envKey resolves by client, not by group (PRN-021)", async () => {
|
||||
// Move the preset DeepSeek model to another group (changing provider is a key change; the paired renamedFrom migrates it).
|
||||
const put = await api.put(url(), {
|
||||
models: [
|
||||
@@ -384,28 +384,28 @@ describe("模型引用改键与连通性测试", () => {
|
||||
expect(moved.envKey).toBe("DEEPSEEK_API_KEY");
|
||||
});
|
||||
|
||||
it("展示名可编辑:与内置目录一致时不落盘;provider 恒作为条目字段落盘", async () => {
|
||||
it("the display name is editable: not persisted when it matches the built-in catalog; provider is always persisted as an entry field", async () => {
|
||||
// Preset model: saved as-is → the display name falls back to the built-in catalog, and display_name isn't written to the config file.
|
||||
await api.put(url(), {
|
||||
models: [
|
||||
{ provider: "openai", modelId: "gpt-5.5", displayName: "GPT-5.5" },
|
||||
{ provider: "openai", modelId: "gpt-5.4", displayName: "我的 GPT" },
|
||||
{ provider: "openai", modelId: "gpt-5.4", displayName: "My GPT" },
|
||||
],
|
||||
});
|
||||
const body = (await (await api.get(url())).json()) as ModelsResponse;
|
||||
expect(pick(body, "openai", "gpt-5.5").displayName).toBe("GPT-5.5");
|
||||
// Edited one: the display name takes effect per the user's setting.
|
||||
expect(pick(body, "openai", "gpt-5.4").displayName).toBe("我的 GPT");
|
||||
expect(pick(body, "openai", "gpt-5.4").displayName).toBe("My GPT");
|
||||
|
||||
// Clean on disk: unchanged preset models don't write display_name; provider is stored as a separate column, no concatenated string.
|
||||
const toml = await readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8");
|
||||
expect(toml).not.toContain('display_name = "GPT-5.5"');
|
||||
expect(toml).toContain('display_name = "我的 GPT"');
|
||||
expect(toml).toContain('display_name = "My GPT"');
|
||||
expect(toml).toContain('provider = "openai"');
|
||||
expect(toml).not.toContain("openai/gpt-5.5");
|
||||
});
|
||||
|
||||
it("renamedFrom 非法值 400;不带 renamedFrom 改键等于删旧建新(credential 不迁移)", async () => {
|
||||
it("an invalid renamedFrom is 400; rekeying without renamedFrom equals delete-old-then-create-new (the credential does not migrate)", async () => {
|
||||
await api.put(url(), {
|
||||
models: [{ provider: "custom", modelId: "m-a", apiKey: "sk-secret-abcd1234" }],
|
||||
});
|
||||
@@ -426,7 +426,7 @@ describe("模型引用改键与连通性测试", () => {
|
||||
expect(body.models[0]!.credential).toBeUndefined();
|
||||
});
|
||||
|
||||
it("连通性测试发的是条目的上游 model_id(引用成对随请求体)", async () => {
|
||||
it("the connectivity test sends the entry's upstream model_id (the reference pair travels with the request body)", async () => {
|
||||
// Local openai-compatible endpoint: records the model field from the request body, then always rejects with 401 (never hits the network).
|
||||
const seenModels: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
@@ -472,7 +472,7 @@ describe("模型引用改键与连通性测试", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("连通性测试请求体不含 tools 与 tool_choice(空工具列表整体省略,vLLM 等严格端点不再 400)", async () => {
|
||||
it("the connectivity-test request body carries no tools or tool_choice (an empty tool list is omitted entirely, so strict endpoints such as vLLM no longer 400)", async () => {
|
||||
// The probe runs with an empty tool list. The wire body must omit `tools` entirely —
|
||||
// `tools: []` is rejected by strict OpenAI-compatible servers (vLLM: "tools must not be an
|
||||
// empty array") — and must never carry `tool_choice`.
|
||||
@@ -512,7 +512,7 @@ describe("模型引用改键与连通性测试", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("连通性测试:已保存的模型与**尚未保存**的自定义模型都可测(LLM 层不抛异常,一律收敛)", async () => {
|
||||
it("connectivity test: both a saved model and a **not-yet-saved** custom model can be tested (the LLM layer throws nothing, every outcome converges)", async () => {
|
||||
await api.put(url(), {
|
||||
models: [{ provider: "openai", modelId: "gpt-5.5", apiKey: "sk-invalid-key-for-test" }],
|
||||
});
|
||||
@@ -536,7 +536,7 @@ describe("模型引用改键与连通性测试", () => {
|
||||
expect(typeof unsavedBody.message).toBe("string");
|
||||
}, 40_000);
|
||||
|
||||
it("连通性测试:模型完全没有 credential 时收敛为 ok:false,而非 500", async () => {
|
||||
it("connectivity test: a model with no credential at all converges to ok:false instead of 500", async () => {
|
||||
// A model using the OpenAI protocol: the provider SDK throws at **client construction** because
|
||||
// the key is missing — if that construction were outside the try it would bubble up as a 500. Clear the env-var key so there's nowhere to get one (no real network request).
|
||||
const prev = process.env.OPENAI_API_KEY;
|
||||
@@ -555,7 +555,7 @@ describe("模型引用改键与连通性测试", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("连通性测试:clearApiKey 时不回落已存 key(照当前草稿测)", async () => {
|
||||
it("connectivity test: clearApiKey does not fall back to the stored key (the current draft is what gets tested)", async () => {
|
||||
// A key is already saved, but the test request carries clearApiKey — the server must **not** use
|
||||
// the saved key. Clear the env var so "don't use the saved key" == no credential at all, so construction synchronously throws missing-credential (no network request, deterministic).
|
||||
const prev = process.env.OPENAI_API_KEY;
|
||||
|
||||
@@ -100,21 +100,21 @@ describe("title-generator", () => {
|
||||
CTX,
|
||||
fakeSession(
|
||||
{
|
||||
title: "Tailwind 主题配置",
|
||||
title: "Tailwind theme setup",
|
||||
usage: { cache_read: 1, cache_write: 2, output: 3, total: 6 },
|
||||
},
|
||||
calls,
|
||||
),
|
||||
{ fallbackText: "解释一下 @theme" },
|
||||
{ fallbackText: "explain @theme" },
|
||||
);
|
||||
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
|
||||
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("Tailwind 主题配置");
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("Tailwind theme setup");
|
||||
// No material override passed: generateTitle is called with no argument, and the core Session gathers its own material.
|
||||
expect(calls.args[0]).toBeUndefined();
|
||||
expect(
|
||||
serverEvents(events).some(
|
||||
(e) => e.type === "session_title" && e.title === "Tailwind 主题配置",
|
||||
(e) => e.type === "session_title" && e.title === "Tailwind theme setup",
|
||||
),
|
||||
).toBe(true);
|
||||
// usage is converted into token_usage and handed to the recorder (metered normally, same as a real call).
|
||||
@@ -125,14 +125,14 @@ describe("title-generator", () => {
|
||||
it("material override (sub-session scenario) is passed to generateTitle verbatim", async () => {
|
||||
const calls = { count: 0, args: [] as unknown[] };
|
||||
const gen = makeGenerator();
|
||||
const material = { userText: "子会话 prompt", assistantText: "子会话回答" };
|
||||
gen.maybeGenerate(CTX, fakeSession({ title: "子标题", usage: null }, calls), {
|
||||
fallbackText: "子会话 prompt",
|
||||
const material = { userText: "sub-session prompt", assistantText: "sub-session reply" };
|
||||
gen.maybeGenerate(CTX, fakeSession({ title: "Sub title", usage: null }, calls), {
|
||||
fallbackText: "sub-session prompt",
|
||||
material,
|
||||
});
|
||||
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
|
||||
expect(calls.args[0]).toEqual({ material });
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("子标题");
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("Sub title");
|
||||
});
|
||||
|
||||
it("an existing title is never regenerated (no one-shot request issued)", async () => {
|
||||
@@ -155,11 +155,11 @@ describe("title-generator", () => {
|
||||
{ title: null, usage: { cache_read: 0, cache_write: 0, output: 1, total: 1 } },
|
||||
calls,
|
||||
),
|
||||
{ fallbackText: "配置 Tailwind 主题\n第二行" },
|
||||
{ fallbackText: "Configure the Tailwind theme\nsecond line" },
|
||||
);
|
||||
// Fallback = the material's first non-empty line, sanitized and truncated, guaranteeing a title is always produced.
|
||||
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("配置 Tailwind 主题");
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("Configure the Tailwind theme");
|
||||
// The one-off request's usage is still recorded normally.
|
||||
await waitFor(() => recorded.length > 0);
|
||||
});
|
||||
@@ -168,21 +168,21 @@ describe("title-generator", () => {
|
||||
const calls = { count: 0, args: [] as unknown[] };
|
||||
const gen = makeGenerator();
|
||||
gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), {
|
||||
fallbackText: "[use_skills]\nskills: web-design\n[/use_skills]\n做一个落地页",
|
||||
fallbackText: "[use_skills]\nskills: web-design\n[/use_skills]\nBuild a landing page",
|
||||
});
|
||||
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
|
||||
// Not "[use_skills]" — the marker block is removed before the first line is taken.
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页");
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("Build a landing page");
|
||||
});
|
||||
|
||||
it("fallback also strips the legacy angle-bracket <use_skills> block (material from old Traces)", async () => {
|
||||
const calls = { count: 0, args: [] as unknown[] };
|
||||
const gen = makeGenerator();
|
||||
gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), {
|
||||
fallbackText: "<use_skills>\nskills: web-design\n</use_skills>\n做一个落地页",
|
||||
fallbackText: "<use_skills>\nskills: web-design\n</use_skills>\nBuild a landing page",
|
||||
});
|
||||
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页");
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("Build a landing page");
|
||||
});
|
||||
|
||||
it("LLM returns null and the fallback material is blank → the title stays NULL (retryable next time)", async () => {
|
||||
|
||||
@@ -1236,7 +1236,7 @@ export function Sidebar({
|
||||
* pinned indicator. The header row carries the `group/header` scope so the reveal only
|
||||
* reacts to its own row, not to the session rows' plain `group` scope.
|
||||
* The accessible name stays STATIC and aria-pressed alone carries the state (the toggle
|
||||
* pattern the grouping-mode buttons use) — a name that swaps 置顶/取消置顶 alongside
|
||||
* pattern the grouping-mode buttons use) — a name that swaps Pin/Unpin alongside
|
||||
* aria-pressed reads as "Unpin group, pressed", saying the state twice in conflicting
|
||||
* ways. The title tooltip may still swap: it is presentation for pointer users and does
|
||||
* not feed the accessible name while aria-label is present.
|
||||
|
||||
@@ -44,7 +44,7 @@ type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules
|
||||
* The "" (not-overridden / inherit) row is filtered out per review — the menus offer only
|
||||
* concrete values in dictionary order, the user picks explicitly. An unset stored value
|
||||
* simply matches no row, so the OptionMenu trigger falls back to its placeholder
|
||||
* ((缺省)/(default), the same convention as the tools-table permission menu); nothing is
|
||||
* ("(default)", the same convention as the tools-table permission menu); nothing is
|
||||
* ever written silently, and the reset link next to each menu rewinds a local pick back to "".
|
||||
*/
|
||||
export function optionRows(
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ApprovalButtons({
|
||||
};
|
||||
|
||||
// Text at every breakpoint (per review): action buttons the user presses must read as words —
|
||||
// "允许/Allow" and "拒绝/Deny" — never as bare glyphs; iconic shorthand is reserved for
|
||||
// "Allow" and "Deny" — never as bare glyphs; iconic shorthand is reserved for
|
||||
// passive indicators. The buttons live on their own row under the (wrapping) argument
|
||||
// preview, so the words cost no width the pending card doesn't already have.
|
||||
return (
|
||||
|
||||
@@ -683,8 +683,8 @@ const versionBadgeClass =
|
||||
"ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80";
|
||||
|
||||
/**
|
||||
* Quiet version line under the brand subtitle: `vX.Y.Z · 最近更新日期 7 月 26 日` /
|
||||
* `… · Last updated Jul 26`. The product name is not repeated here — the brand wordmark
|
||||
* Quiet version line under the brand subtitle: `vX.Y.Z · Last updated Jul 26`
|
||||
* (localized per dictionary). The product name is not repeated here — the brand wordmark
|
||||
* sits directly above, and the sidebar's version footer is bare `vX.Y.Z` too. The date is
|
||||
* the running version's release
|
||||
* date, stamped into core's BUILD_DATE at build time — displayed as-is, no network;
|
||||
|
||||
@@ -205,7 +205,7 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
: failed
|
||||
? "failed"
|
||||
: "done";
|
||||
// Decision wording ("Approved · manual" / "已拒绝 · 手动" …): carried ONLY by the left status
|
||||
// Decision wording ("Approved · manual", "Denied · manual", …): carried ONLY by the left status
|
||||
// icon's title/aria-label — per review the row shows no visible decision text at any
|
||||
// breakpoint; the icon is the single source of truth for how the call was decided.
|
||||
const decisionText = item.decision
|
||||
|
||||
@@ -21,16 +21,16 @@
|
||||
* Agent's v_old → v_new and warns the overwriting reinstall drops local
|
||||
* edits), then reinstalls the current library copy on every outdated Agent
|
||||
* (install-again-is-update semantics), with a single success toast; the
|
||||
* manage-installs Modal marks outdated rows with an accent "更新"/"Update"
|
||||
* button doing the same per Agent (through the same confirm);
|
||||
* manage-installs Modal marks outdated rows with an accent "Update" button
|
||||
* doing the same per Agent (through the same confirm);
|
||||
* - Paper plane "quick invoke": enters /chat/new draft mode with default_agent,
|
||||
* pre-selects the skill, and pre-fills the invocation text per UI language
|
||||
* (zh "使用 X 技能" / en "use the X skill", overwriting any existing draft body);
|
||||
* ("use the X skill" in the active dictionary, overwriting any existing draft body);
|
||||
* - Download "manage installs": a Modal listing every Agent in the current
|
||||
* Project — not-installed shows "安装"/"Install", installed shows
|
||||
* "已安装"/"Installed" (hover switches to "卸载"/"Uninstall", click to
|
||||
* uninstall); any member can operate it; optimistic update, a top-level
|
||||
* toast on success for install/uninstall, rollback plus a toast on failure.
|
||||
* Project — not-installed shows "Install", installed shows "Installed"
|
||||
* (hover switches to "Uninstall", click to uninstall); any member can
|
||||
* operate it; optimistic update, a top-level toast on success for
|
||||
* install/uninstall, rollback plus a toast on failure.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
@@ -550,11 +550,11 @@ function SkillCard({
|
||||
|
||||
/**
|
||||
* One Agent row in the "manage installs" Modal: not-installed shows
|
||||
* "安装"/"Install"; installed shows "已安装"/"Installed", switching to
|
||||
* "卸载"/"Uninstall" on hover (same button, click to uninstall); an installed
|
||||
* copy older than the library additionally shows an accent "更新"/"Update"
|
||||
* button (reinstall = update). Install and uninstall go through optimistic
|
||||
* updates (toggleInstall), rolling back on failure.
|
||||
* "Install"; installed shows "Installed", switching to "Uninstall" on hover
|
||||
* (same button, click to uninstall); an installed copy older than the library
|
||||
* additionally shows an accent "Update" button (reinstall = update). Install
|
||||
* and uninstall go through optimistic updates (toggleInstall), rolling back on
|
||||
* failure.
|
||||
*/
|
||||
function InstallRow({
|
||||
agentId,
|
||||
@@ -589,7 +589,7 @@ function InstallRow({
|
||||
</Button>
|
||||
)}
|
||||
{installed ? (
|
||||
// group: on hover the button's copy switches "已安装"/"Installed" → "卸载"/"Uninstall" (the same button carries the uninstall action).
|
||||
// group: on hover the button's copy switches "Installed" → "Uninstall" (the same button carries the uninstall action).
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -334,13 +334,13 @@ export function liveSessionElapsedMs(
|
||||
|
||||
/** Localized labels for {@link formatTaskStats} (supplied by the view layer's active dictionary). */
|
||||
export interface TaskStatsLabels {
|
||||
/** Row prefix, e.g. "Stats" / "统计信息". */
|
||||
/** Row prefix, e.g. "Stats". */
|
||||
stats: string;
|
||||
/** Input-tokens label, e.g. "Input tokens" / "输入 tokens". */
|
||||
/** Input-tokens label, e.g. "Input tokens". */
|
||||
input: string;
|
||||
/** Cached-amount label, e.g. "cached" / "已缓存". */
|
||||
/** Cached-amount label, e.g. "cached". */
|
||||
cached: string;
|
||||
/** Output-tokens label, e.g. "Output tokens" / "输出 tokens". */
|
||||
/** Output-tokens label, e.g. "Output tokens". */
|
||||
output: string;
|
||||
/** Opening wrapper before the cached amount — keeps per-locale typography (e.g. " (" / "("). */
|
||||
parenOpen: string;
|
||||
|
||||
@@ -21,7 +21,7 @@ export const en: Strings = {
|
||||
benchmark: "Evaluation Center",
|
||||
// Collapsed-rail tooltips (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above).
|
||||
lastConversation: "Last conversation",
|
||||
// Deliberately equal to nav.agents: the key exists for the zh-only wording difference (智能体 vs 智能体仓库).
|
||||
// Deliberately equal to nav.agents: the key exists only because the zh dictionary words the rail entry differently ("Agents" vs "Agent library").
|
||||
railAgents: "Agents",
|
||||
collapseSidebar: "Collapse sidebar",
|
||||
expandSidebar: "Expand sidebar",
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("skillSlashItems (slash skill command item assembly)", () => {
|
||||
});
|
||||
|
||||
describe("quickInvokeText (prefill text for skill-library quick invoke, zh/en dictionaries)", () => {
|
||||
it('same convention as the empty-body auto-invoke text: zh "使用 X 技能" / en "use the X skill"', () => {
|
||||
it('same convention as the empty-body auto-invoke text: "use the X skill", localized per dictionary', () => {
|
||||
expect(zh.skills.quickInvokeText("agent-creation")).toBe("使用 agent-creation 技能");
|
||||
expect(en.skills.quickInvokeText("agent-creation")).toBe("use the agent-creation skill");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Unit tests for task-stats.ts: the stats-row convention matches the CLI's "统计信息"
|
||||
* Unit tests for task-stats.ts: the stats-row convention matches the CLI's stats-row
|
||||
* output item for item.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
Reference in New Issue
Block a user