feat(studio): bilingual chat dynamic content

新增 app-language 全局语言工具(tr/getAppLanguage,非 React 模块用);
工具执行块、工具状态、agent/工具徽标、聊天错误提示、生成物抽屉、
error-copy 全部双语。默认 zh,现有断言不变
This commit is contained in:
Ma
2026-07-07 17:23:03 +08:00
parent ff5cdd9809
commit 2bd7e9ae22
12 changed files with 326 additions and 121 deletions
@@ -20,6 +20,7 @@ import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
import { tr } from "@/lib/app-language";
export type ToolProps = ComponentProps<typeof Collapsible>;
@@ -44,14 +45,16 @@ export type ToolHeaderProps = {
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "等待确认",
"approval-responded": "已响应",
"input-available": "执行中",
"input-streaming": "处理中",
"output-available": "已完成",
"output-denied": "已拒绝",
"output-error": "出错",
// [zh, en] tuples resolved through tr() at render time so the badge follows
// the current app language instead of the language active at module load.
const statusLabels: Record<ToolPart["state"], readonly [string, string]> = {
"approval-requested": ["等待确认", "Awaiting approval"],
"approval-responded": ["已响应", "Responded"],
"input-available": ["执行中", "Running"],
"input-streaming": ["处理中", "Processing"],
"output-available": ["已完成", "Completed"],
"output-denied": ["已拒绝", "Denied"],
"output-error": ["出错", "Error"],
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
@@ -67,7 +70,7 @@ const statusIcons: Record<ToolPart["state"], ReactNode> = {
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
{tr(statusLabels[status][0], statusLabels[status][1])}
</Badge>
);
@@ -3,6 +3,7 @@ import { cjk } from "@streamdown/cjk";
import { AlertCircle, Loader2, Pencil, Save, X } from "lucide-react";
import { Streamdown } from "streamdown";
import { fetchJson } from "../../hooks/use-api";
import { tr } from "../../lib/app-language";
import { useChatStore } from "../../store/chat";
interface ProjectArtifactPayload {
@@ -104,7 +105,7 @@ export function ProjectArtifactDrawer() {
<div className="fixed inset-0 z-[80] flex justify-end bg-background/35 backdrop-blur-[2px]">
<button
type="button"
aria-label="关闭生成物预览"
aria-label={tr("关闭生成物预览", "Close artifact preview")}
className="absolute inset-0 cursor-default"
onClick={close}
/>
@@ -112,7 +113,7 @@ export function ProjectArtifactDrawer() {
<header className="flex items-start justify-between gap-4 border-b border-border/45 px-6 py-5">
<div className="min-w-0">
<div className="text-[13px] font-medium uppercase tracking-[0.18em] text-muted-foreground/65">
{tr("生成物", "Artifact")}
</div>
<h2 className="mt-1 truncate text-[22px] font-semibold text-foreground">
{displayName(path)}
@@ -132,7 +133,7 @@ export function ProjectArtifactDrawer() {
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-secondary/35 px-3 py-2 text-[14px] font-medium text-foreground transition hover:border-primary/45 hover:bg-primary/10"
>
<Pencil size={15} />
{tr("编辑", "Edit")}
</button>
)}
{editing && (
@@ -146,7 +147,7 @@ export function ProjectArtifactDrawer() {
disabled={saving}
className="rounded-lg border border-border/60 px-3 py-2 text-[14px] font-medium text-muted-foreground transition hover:bg-secondary/50 disabled:opacity-60"
>
{tr("取消", "Cancel")}
</button>
<button
type="button"
@@ -155,7 +156,7 @@ export function ProjectArtifactDrawer() {
className="inline-flex items-center gap-2 rounded-lg bg-primary px-3 py-2 text-[14px] font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Save size={15} />}
{tr("保存", "Save")}
</button>
</>
)}
@@ -163,7 +164,7 @@ export function ProjectArtifactDrawer() {
type="button"
onClick={close}
className="rounded-lg border border-border/50 p-2 text-muted-foreground transition hover:bg-secondary/60 hover:text-foreground"
aria-label="关闭"
aria-label={tr("关闭", "Close")}
>
<X size={18} />
</button>
@@ -181,7 +182,7 @@ export function ProjectArtifactDrawer() {
{loading ? (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 size={22} className="mr-2 animate-spin" />
...
{tr("正在读取生成物...", "Loading artifact...")}
</div>
) : editing ? (
<textarea
@@ -204,7 +205,7 @@ export function ProjectArtifactDrawer() {
)
) : (
<div className="rounded-xl border border-dashed border-border/55 px-4 py-8 text-center text-[14px] text-muted-foreground">
{tr("没有可预览内容。", "Nothing to preview.")}
</div>
)}
</div>
@@ -14,6 +14,7 @@ import {
Check,
} from "lucide-react";
import { buildApiUrl } from "../../hooks/use-api";
import { tr } from "../../lib/app-language";
import { chatSelectors, useChatStore } from "../../store/chat";
import { usePreferencesStore } from "../../store/preferences";
@@ -25,28 +26,28 @@ function ExecStatusBadge({ status }: { status: ToolExecution["status"] }) {
return (
<span className="inline-flex items-center gap-1 text-xs text-primary">
<Loader2 size={12} className="animate-spin" />
<span></span>
<span>{tr("执行中", "Running")}</span>
</span>
);
case "processing":
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 size={12} className="animate-spin" style={{ animationDuration: "2s" }} />
<span></span>
<span>{tr("处理结果", "Processing result")}</span>
</span>
);
case "completed":
return (
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<CheckCircle2 size={12} />
<span></span>
<span>{tr("已完成", "Completed")}</span>
</span>
);
case "error":
return (
<span className="inline-flex items-center gap-1 text-xs text-destructive">
<XCircle size={12} />
<span></span>
<span>{tr("失败", "Failed")}</span>
</span>
);
}
@@ -65,7 +66,7 @@ function StageIcon({ status }: { status: PipelineStage["status"] }) {
function formatProgress(progress: NonNullable<PipelineStage["progress"]>): string {
const secs = Math.round(progress.elapsedMs / 1000);
const statusLabel = progress.status === "thinking" ? "思考中" : progress.status ?? "";
const statusLabel = progress.status === "thinking" ? tr("思考中", "Thinking") : progress.status ?? "";
const chars = progress.totalChars > 0
? progress.chineseChars > 0 ? `${progress.totalChars}` : `${progress.totalChars} chars`
: "";
@@ -230,14 +231,14 @@ function ScriptStoryboardResultPreview({ exec, onOpenFilmStudio }: { exec: ToolE
&& details.kind !== "interactive_film_created"
)) return null;
const maybeRows: Array<readonly [string, string] | null> = [
details.specPath ? ["规格", details.specPath] : null,
details.storyGraphPath ? ["剧情图谱", details.storyGraphPath] : null,
details.storyTreePath ? ["剧情树", details.storyTreePath] : null,
details.flagsPath ? ["变量旗标", details.flagsPath] : null,
details.scriptPath ? ["剧本", details.scriptPath] : null,
details.storyboardPath ? ["分镜", details.storyboardPath] : null,
details.imagePromptsPath ? ["图像提示词", details.imagePromptsPath] : null,
details.assetsManifestPath ? ["图片资产", details.assetsManifestPath] : null,
details.specPath ? [tr("规格", "Spec"), details.specPath] : null,
details.storyGraphPath ? [tr("剧情图谱", "Story graph"), details.storyGraphPath] : null,
details.storyTreePath ? [tr("剧情树", "Story tree"), details.storyTreePath] : null,
details.flagsPath ? [tr("变量旗标", "Flags"), details.flagsPath] : null,
details.scriptPath ? [tr("剧本", "Script"), details.scriptPath] : null,
details.storyboardPath ? [tr("分镜", "Storyboard"), details.storyboardPath] : null,
details.imagePromptsPath ? [tr("图像提示词", "Image prompts"), details.imagePromptsPath] : null,
details.assetsManifestPath ? [tr("图片资产", "Image assets"), details.assetsManifestPath] : null,
];
const rows = maybeRows.filter((row): row is readonly [string, string] => Boolean(row));
if (rows.length === 0 && !(details.kind === "interactive_film_created" && details.projectId)) return null;
@@ -245,7 +246,11 @@ function ScriptStoryboardResultPreview({ exec, onOpenFilmStudio }: { exec: ToolE
<div className="mx-3 mb-3 mt-1 rounded-xl border border-primary/20 bg-primary/5 px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="text-[16px] leading-6 font-semibold text-primary">
{details.kind === "script_created" ? "剧本已生成" : details.kind === "storyboard_created" ? "分镜已生成" : "互动影游已生成"}
{details.kind === "script_created"
? tr("剧本已生成", "Script generated")
: details.kind === "storyboard_created"
? tr("分镜已生成", "Storyboard generated")
: tr("互动影游已生成", "Interactive film generated")}
</div>
{details.kind === "interactive_film_created" && details.projectId && onOpenFilmStudio && (
<button
@@ -254,7 +259,7 @@ function ScriptStoryboardResultPreview({ exec, onOpenFilmStudio }: { exec: ToolE
onClick={() => onOpenFilmStudio(details.projectId!)}
className="shrink-0 rounded-lg bg-primary px-3 py-1 text-[13px] font-semibold text-primary-foreground hover:opacity-90 transition-opacity"
>
{tr("打开创作向导 →", "Open creation wizard →")}
</button>
)}
</div>
@@ -268,10 +273,10 @@ function ScriptStoryboardResultPreview({ exec, onOpenFilmStudio }: { exec: ToolE
className="group flex w-full items-start justify-between gap-3 rounded-lg border border-transparent px-2 py-1.5 text-left transition hover:border-primary/25 hover:bg-background/65"
>
<span className="min-w-0 text-[13px] leading-5 text-muted-foreground break-all">
<span className="font-medium text-foreground">{label}</span>{path}
<span className="font-medium text-foreground">{label}{tr("", ": ")}</span>{path}
</span>
<span className="mt-0.5 shrink-0 rounded-md border border-primary/25 bg-primary/10 px-1.5 py-0.5 text-[11px] font-semibold text-primary opacity-80 transition group-hover:opacity-100">
{tr("查看", "View")}
</span>
</button>
))}
@@ -290,14 +295,14 @@ function ShortFictionResultPreview({ exec }: { exec: ToolExecution }) {
if (!coverError) return null;
return (
<div className="mx-3 mb-3 mt-1 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{coverError}
{tr("封面未生成:", "Cover not generated: ")}{coverError}
</div>
);
}
const coverUrl = buildApiUrl(`/project/files/${encodeProjectPath(coverPath)}`);
if (!coverUrl) return null;
const title = details?.title ?? details?.storyId ?? "短篇封面";
const title = details?.title ?? details?.storyId ?? tr("短篇封面", "Short fiction cover");
return (
<div className="mx-3 mb-3 mt-1 overflow-hidden rounded-xl border border-border/40 bg-background/70">
@@ -420,13 +425,13 @@ function PlaySceneImagePreview({ details }: { details: PlayToolDetails }) {
<div className="mt-3 overflow-hidden rounded-xl border border-border/40 bg-background/80">
<img
src={readyUrl}
alt="本幕配图"
alt={tr("本幕配图", "Scene illustration")}
className="block max-h-[420px] w-full object-contain bg-muted/20"
loading="lazy"
/>
{details.turn != null && (
<div className="border-t border-border/40 px-3 py-2.5 text-[14px] leading-6 text-muted-foreground">
{Math.trunc(details.turn)}
{tr(`${Math.trunc(details.turn)} 幕配图`, `Scene ${Math.trunc(details.turn)} illustration`)}
</div>
)}
</div>
@@ -477,9 +482,9 @@ export function getProposedActionContractRows(details: ProposedActionDetails): R
if (details.action !== "play_start" || !playStart) return [];
const rows: Array<{ label: string; value: string }> = [];
const worldContract = playStart.worldContract?.trim();
if (worldContract) rows.push({ label: "世界契约", value: worldContract });
if (worldContract) rows.push({ label: tr("世界契约", "World contract"), value: worldContract });
const visualContract = playStart.visualContract?.trim();
if (visualContract) rows.push({ label: "视觉契约", value: visualContract });
if (visualContract) rows.push({ label: tr("视觉契约", "Visual contract"), value: visualContract });
return rows;
}
@@ -506,7 +511,7 @@ function ProposedActionPreview({
const contractRows = getProposedActionContractRows(details);
return (
<div className="mx-3 mb-3 mt-1 rounded-xl border border-primary/25 bg-primary/5 px-4 py-3.5">
<div className="text-[17px] leading-6 font-semibold text-foreground">{details.title ?? "确认执行"}</div>
<div className="text-[17px] leading-6 font-semibold text-foreground">{details.title ?? tr("确认执行", "Confirm action")}</div>
{details.summary && (
<div className="mt-1.5 whitespace-pre-wrap break-words text-[15px] leading-7 text-muted-foreground">{details.summary}</div>
)}
@@ -526,10 +531,10 @@ function ProposedActionPreview({
{resolution === "confirmed" ? (
<div className="mt-3 flex items-center gap-1.5 text-[15px] leading-6 font-medium text-primary">
<Check size={15} className="shrink-0" />
{details.targetRoute ? "已打开" : "已执行"}
{details.targetRoute ? tr("已打开", "Opened") : tr("已执行", "Executed")}
</div>
) : resolution === "rejected" ? (
<div className="mt-3 text-[15px] leading-6 font-medium text-muted-foreground"></div>
<div className="mt-3 text-[15px] leading-6 font-medium text-muted-foreground">{tr("已取消", "Cancelled")}</div>
) : (
<div className="mt-3 flex flex-wrap gap-2">
<button
@@ -539,7 +544,7 @@ function ProposedActionPreview({
disabled={!onProposedAction || streaming || locked}
className="rounded-lg bg-primary px-3.5 py-2 text-[15px] leading-6 font-medium text-primary-foreground disabled:opacity-50"
>
{streaming ? "执行中…" : details.targetRoute ? "打开入口" : "继续执行"}
{streaming ? tr("执行中…", "Running…") : details.targetRoute ? tr("打开入口", "Open entry") : tr("继续执行", "Continue")}
</button>
<button
type="button"
@@ -547,7 +552,7 @@ function ProposedActionPreview({
disabled={!onRejectProposedAction || streaming || locked}
className="rounded-lg border border-border/60 bg-background/80 px-3.5 py-2 text-[15px] leading-6 font-medium text-muted-foreground disabled:opacity-50"
>
{tr("取消", "Cancel")}
</button>
</div>
)}
@@ -560,12 +565,12 @@ function PlayResultPreview({ exec }: { exec: ToolExecution }) {
const details = getPlayToolDetails(exec);
if (!details?.sceneText) return null;
const label = details.kind === "play_world_started"
? "互动世界已启动"
? tr("互动世界已启动", "Interactive world started")
: details.kind === "play_turn_revised"
? "互动回合已重做"
? tr("互动回合已重做", "Play turn redone")
: details.kind === "play_variant_restored"
? "已切换互动回合版本"
: "互动世界已推进";
? tr("已切换互动回合版本", "Switched play turn variant")
: tr("互动世界已推进", "Interactive world advanced");
return (
<div className="mx-3 mb-3 mt-1 rounded-xl border border-primary/20 bg-primary/5 px-3 py-3">
<div className="mb-2 text-[16px] leading-6 font-semibold text-primary">
@@ -582,16 +587,18 @@ function PlayEditPreview({ exec }: { exec: ToolExecution }) {
const details = getPlayEditDetails(exec);
if (!details) return null;
const changes = [
details.updatedWorldContract ? "世界契约" : "",
details.updatedVisualContract ? "视觉契约" : "",
details.updatedPremise ? "世界前提" : "",
details.updatedEntities && details.updatedEntities > 0 ? `${details.updatedEntities} 张卡片` : "",
details.updatedWorldContract ? tr("世界契约", "World contract") : "",
details.updatedVisualContract ? tr("视觉契约", "Visual contract") : "",
details.updatedPremise ? tr("世界前提", "World premise") : "",
details.updatedEntities && details.updatedEntities > 0
? tr(`${details.updatedEntities} 张卡片`, `${details.updatedEntities} cards`)
: "",
].filter(Boolean);
return (
<div className="mx-3 mb-3 mt-1 rounded-xl border border-primary/20 bg-primary/5 px-3 py-2.5">
<div className="text-[16px] leading-6 font-semibold text-primary"></div>
<div className="text-[16px] leading-6 font-semibold text-primary">{tr("互动世界设定已更新", "Interactive world settings updated")}</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{changes.length > 0 ? changes.join(" · ") : "已写入当前世界。"}
{changes.length > 0 ? changes.join(" · ") : tr("已写入当前世界。", "Written to the current world.")}
</div>
</div>
);
@@ -641,7 +648,7 @@ export function PipelineResultDetails({ result, defaultOpen }: { result: string;
className="mx-3 mb-3 mt-1 rounded-lg border border-border/40 bg-background/60 px-2.5 py-2 text-xs"
>
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
{tr("查看操作结果", "View result")}
</summary>
<div className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap break-words leading-5 text-foreground">
{result}
@@ -809,7 +816,7 @@ function UtilityToolsGroup({ execs }: { execs: ToolExecution[] }) {
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 px-2 py-1 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer text-xs text-muted-foreground">
<Wrench size={12} />
<span>{execs.length} </span>
<span>{tr(`${execs.length} 个文件操作`, `${execs.length} file operation${execs.length === 1 ? "" : "s"}`)}</span>
{allDone && !hasError && <CheckCircle2 size={10} className="text-green-600 dark:text-green-400" />}
{hasError && <XCircle size={10} className="text-destructive" />}
{!allDone && <Loader2 size={10} className="animate-spin text-primary" />}
@@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { ToolExecution } from "../../../store/chat/types";
import { PipelineResultDetails, ToolExecutionSteps, UtilityExecutionRow, buildPlayRunStatusUrl, buildPlaySceneImageUrl, getGeneratedArtifactDetails, getPlayEditDetails, getPlayToolDetails, getProposedActionContractRows, getProposedActionDetails, groupToolExecutionsChronologically } from "../ToolExecutionSteps";
import { usePreferencesStore } from "../../../store/preferences";
import { setAppLanguage } from "../../../lib/app-language";
const makeExec = (overrides: Partial<ToolExecution> & { id: string; tool: string }): ToolExecution => ({
label: "test",
@@ -513,6 +514,83 @@ describe("tool details default-open preference", () => {
});
});
describe("English app language", () => {
beforeEach(() => {
setAppLanguage("en");
});
afterEach(() => {
setAppLanguage("zh");
});
it("renders pipeline status, result summary, and file-operation group in English", () => {
const execs: ToolExecution[] = [
makeExec({
id: "writer-en-1",
tool: "sub_agent",
agent: "writer",
label: "Write",
result: "Chapter 1 finished.",
}),
makeExec({ id: "read-en-1", tool: "read", label: "Read file", args: { path: "books/demo/chapter-1.md" } }),
];
const html = renderToStaticMarkup(React.createElement(ToolExecutionSteps, { executions: execs }));
expect(html).toContain("Completed");
expect(html).toContain("View result");
expect(html).toContain("1 file operation");
expect(html).not.toContain("已完成");
expect(html).not.toContain("查看操作结果");
});
it("renders interactive-film artifacts and proposal contract rows in English", () => {
const filmExec = makeExec({
id: "interactive-film-en-1",
tool: "interactive_film_create",
label: "Interactive film",
details: {
kind: "interactive_film_created",
projectId: "demo-branching",
storyGraphPath: "interactive-films/demo-branching/story-graph.json",
storyTreePath: "interactive-films/demo-branching/story-tree.md",
},
});
const html = renderToStaticMarkup(React.createElement(ToolExecutionSteps, { executions: [filmExec] }));
expect(html).toContain("Interactive film generated");
expect(html).toContain("Story graph");
expect(html).toContain("Story tree");
expect(html).not.toContain("互动影游已生成");
const proposalExec = makeExec({
id: "proposal-en-1",
tool: "propose_action",
label: "Confirm action",
details: {
kind: "proposed_action",
action: "play_start",
targetSessionKind: "play",
instruction: "Start a cultivation open world.",
actionPayload: {
playStart: {
title: "Outer Gate",
worldContract: "Time is the shared world axis.",
visualContract: "No colored rarity borders.",
},
},
},
});
const details = getProposedActionDetails(proposalExec);
expect(details).not.toBeNull();
expect(getProposedActionContractRows(details!).map((row) => row.label)).toEqual([
"World contract",
"Visual contract",
]);
});
});
describe("UtilityExecutionRow", () => {
it("renders an expandable, default-collapsed result body when the execution has a result", () => {
const exec = makeExec({
+18
View File
@@ -0,0 +1,18 @@
// 全局应用语言:非 React 模块(store slice、parts-builder、error-copy 等)无法用
// useI18n hook,从这里读取。App.tsx 在项目配置加载/切换语言时调用 setAppLanguage 同步。
export type AppLanguage = "zh" | "en";
let current: AppLanguage = "zh";
export function setAppLanguage(lang: AppLanguage): void {
current = lang;
}
export function getAppLanguage(): AppLanguage {
return current;
}
/** 内联双语:tr("中文", "English")。默认中文,保持既有测试与默认体验不变。 */
export function tr(zh: string, en: string): string {
return current === "en" ? en : zh;
}
+4
View File
@@ -1,3 +1,5 @@
import { getAppLanguage } from "./app-language";
const KNOWN_RUNTIME_REPLACEMENTS: ReadonlyArray<{
readonly pattern: RegExp;
readonly replacement: string;
@@ -29,6 +31,8 @@ const KNOWN_RUNTIME_REPLACEMENTS: ReadonlyArray<{
];
export function localizeKnownRuntimeMessage(message: string): string {
// Runtime messages arrive in English; in English mode show them as-is.
if (getAppLanguage() === "en") return message;
let localized = message;
for (const entry of KNOWN_RUNTIME_REPLACEMENTS) {
localized = localized.replace(entry.pattern, entry.replacement);
+8 -5
View File
@@ -551,12 +551,12 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
}, [services, modelsByService]);
const selectedModelLabel = useMemo(() => {
if (!selectedModel) return "选择模型";
if (!selectedModel) return isZh ? "选择模型" : "Select model";
const group = groupedModels.find((item) => item.service === selectedService);
const model = group?.models.find((item) => item.id === selectedModel);
const modelLabel = model?.name ?? selectedModel;
return group ? `${group.label} · ${modelLabel}` : modelLabel;
}, [groupedModels, selectedModel, selectedService]);
}, [groupedModels, selectedModel, selectedService, isZh]);
// Auto-select from saved service config first, then fall back to the first available model.
useEffect(() => {
@@ -804,7 +804,10 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
markProposalResolved(details.execId, "rejected");
if (!activeSessionId) return;
autoScrollPinnedRef.current = true;
await sendMessage(activeSessionId, `取消这次操作:${details.title ?? details.instruction}`, {
const rejectionText = isZh
? `取消这次操作:${details.title ?? details.instruction}`
: `Cancel this action: ${details.title ?? details.instruction}`;
await sendMessage(activeSessionId, rejectionText, {
activeBookId,
sessionKind: currentSessionKind,
actionSource: "button",
@@ -1181,7 +1184,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
</div>
<div className="flex items-center gap-2 px-3 pb-2 border-t border-border/20 pt-1.5">
{modelPickerStatus === "loading" ? (
<span className="text-[15px] text-muted-foreground/40 animate-pulse">...</span>
<span className="text-[15px] text-muted-foreground/40 animate-pulse">{isZh ? "加载模型..." : "Loading models..."}</span>
) : modelPickerStatus === "ready" ? (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 px-2 py-1.5 rounded-md hover:bg-muted text-[16px] transition-colors cursor-pointer">
@@ -1203,7 +1206,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
onClick={() => nav.toServices()}
className="text-[15px] text-muted-foreground/50 hover:text-primary transition-colors"
>
{isZh ? "配置模型 →" : "Set up models →"}
</button>
)}
{currentSessionKind === "play" && (
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, afterEach } from "vitest";
import { buildPartsFromEvents, type StreamEvent } from "../parts-builder";
import { setAppLanguage } from "../../../lib/app-language";
describe("buildPartsFromEvents", () => {
it("produces thinking → text parts from basic conversation", () => {
@@ -335,3 +336,69 @@ describe("buildPartsFromEvents", () => {
}
});
});
describe("buildPartsFromEvents in English app language", () => {
afterEach(() => {
setAppLanguage("zh");
});
it("resolves agent and tool labels in English", () => {
setAppLanguage("en");
const parts = buildPartsFromEvents([
{ type: "tool:start", id: "t1", tool: "read" },
{ type: "tool:end", id: "t1" },
{ type: "tool:start", id: "t2", tool: "sub_agent", agent: "writer" },
{ type: "tool:end", id: "t2" },
]);
expect(parts[0].type === "tool" ? parts[0].execution.label : "").toBe("Read file");
expect(parts[1].type === "tool" ? parts[1].execution.label : "").toBe("Write");
});
it("labels session context compression and its progress in English", () => {
setAppLanguage("en");
const parts = buildPartsFromEvents([
{
type: "context:compression",
category: "session_context",
phase: "start",
protectedTokens: 1200,
compressibleTokens: 9000,
budgetTokens: 6000,
sources: ["story/current_state.md"],
},
]);
expect(parts).toHaveLength(1);
expect(parts[0].type).toBe("tool");
if (parts[0].type === "tool") {
expect(parts[0].execution.label).toBe("Organize session memory");
const stage = parts[0].execution.stages?.[0];
expect(stage?.label).toBe("Organize session memory");
expect(stage?.progress?.status).toContain("protected 1200");
expect(stage?.progress?.status).toContain("compressible 9000");
expect(stage?.progress?.status).toContain("budget 6000");
expect(stage?.progress?.status).toContain("sources 1");
}
});
it("keeps English runtime error messages untouched", () => {
setAppLanguage("en");
const parts = buildPartsFromEvents([
{ type: "tool:start", id: "t1", tool: "sub_agent", agent: "writer" },
{
type: "tool:end",
id: "t1",
isError: true,
result: "Latest chapter 1 is state-degraded. Repair state or rewrite that chapter before continuing.",
},
]);
expect(parts[0].type).toBe("tool");
if (parts[0].type === "tool") {
expect(parts[0].execution.error).toBe(
"Latest chapter 1 is state-degraded. Repair state or rewrite that chapter before continuing.",
);
}
});
});
+31 -22
View File
@@ -1,5 +1,6 @@
import type { MessagePart, ToolExecution, PipelineStage } from "./types";
import { localizeKnownRuntimeMessage } from "../../lib/error-copy";
import { tr } from "../../lib/app-language";
// -- Event types for the builder --
@@ -30,25 +31,31 @@ export interface ContextCompressionStreamEvent {
// -- Label helpers --
const AGENT_LABELS: Record<string, string> = {
architect: "建书", writer: "写作", auditor: "审计",
reviser: "修订", exporter: "导出",
// [zh, en] tuples resolved through tr() at call time so labels follow the
// current app language instead of the language active at module load.
const AGENT_LABELS: Record<string, readonly [string, string]> = {
architect: ["建书", "Create book"], writer: ["写作", "Write"], auditor: ["审计", "Audit"],
reviser: ["修订", "Revise"], exporter: ["导出", "Export"],
};
const TOOL_LABELS: Record<string, string> = {
read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
context_compression: "整理上下文",
propose_action: "确认动作",
short_fiction_run: "短篇生产",
generate_cover: "生成封面",
play_edit: "编辑互动世界",
play_start: "启动互动世界",
play_revise: "重做互动回合",
play_step: "推进互动世界",
const TOOL_LABELS: Record<string, readonly [string, string]> = {
read: ["读取文件", "Read file"], edit: ["编辑文件", "Edit file"], grep: ["搜索", "Search"], ls: ["列目录", "List directory"],
context_compression: ["整理上下文", "Organize context"],
propose_action: ["确认动作", "Confirm action"],
short_fiction_run: ["短篇生产", "Short fiction run"],
generate_cover: ["生成封面", "Generate cover"],
play_edit: ["编辑互动世界", "Edit interactive world"],
play_start: ["启动互动世界", "Start interactive world"],
play_revise: ["重做互动回合", "Redo play turn"],
play_step: ["推进互动世界", "Advance interactive world"],
};
function resolveToolLabel(tool: string, agent?: string): string {
if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
return TOOL_LABELS[tool] ?? tool;
if (tool === "sub_agent" && agent) {
const label = AGENT_LABELS[agent];
return label ? tr(label[0], label[1]) : agent;
}
const label = TOOL_LABELS[tool];
return label ? tr(label[0], label[1]) : tool;
}
function summarizeToolResult(result: unknown): string {
@@ -71,22 +78,24 @@ function summarizeToolResult(result: unknown): string {
}
function compressionLabel(category: ContextCompressionCategory): string {
return category === "session_context" ? "整理会话记忆" : "压缩故事上下文";
return category === "session_context"
? tr("整理会话记忆", "Organize session memory")
: tr("压缩故事上下文", "Compress story context");
}
function compressionSourceSummary(sources: readonly string[] | undefined): string {
if (!sources || sources.length === 0) return "";
const preview = sources.slice(0, 3).join(", ");
const suffix = sources.length > 3 ? ` +${sources.length - 3}` : "";
return `来源 ${sources.length}: ${preview}${suffix}`;
return `${tr("来源", "sources")} ${sources.length}: ${preview}${suffix}`;
}
function compressionProgress(event: ContextCompressionStreamEvent): PipelineStage["progress"] | undefined {
if (event.phase !== "start") return undefined;
const parts = [
event.protectedTokens !== undefined ? `保护 ${event.protectedTokens}` : "",
event.compressibleTokens !== undefined ? `可压缩 ${event.compressibleTokens}` : "",
event.budgetTokens !== undefined ? `预算 ${event.budgetTokens}` : "",
event.protectedTokens !== undefined ? `${tr("保护", "protected")} ${event.protectedTokens}` : "",
event.compressibleTokens !== undefined ? `${tr("可压缩", "compressible")} ${event.compressibleTokens}` : "",
event.budgetTokens !== undefined ? `${tr("预算", "budget")} ${event.budgetTokens}` : "",
compressionSourceSummary(event.sources),
].filter(Boolean);
return {
@@ -120,7 +129,7 @@ function applyContextCompressionEvent(parts: MessagePart[], event: ContextCompre
runningTool.stages = upsertCompressionStage(runningTool.stages, event);
if (event.phase === "error") {
runningTool.status = "error";
runningTool.error = event.message ?? `${compressionLabel(event.category)}失败`;
runningTool.error = event.message ?? `${compressionLabel(event.category)}${tr("失败", " failed")}`;
}
return;
}
@@ -142,7 +151,7 @@ function applyContextCompressionEvent(parts: MessagePart[], event: ContextCompre
execution.label = compressionLabel(event.category);
execution.stages = upsertCompressionStage(execution.stages, event);
if (event.phase !== "start") execution.completedAt = Date.now();
if (event.phase === "error") execution.error = event.message ?? `${compressionLabel(event.category)}失败`;
if (event.phase === "error") execution.error = event.message ?? `${compressionLabel(event.category)}${tr("失败", " failed")}`;
if (!existing) parts.push({ type: "tool", execution });
}
@@ -10,6 +10,7 @@ import type {
SessionSummary,
} from "../../types";
import { fetchJson } from "../../../../hooks/use-api";
import { tr } from "../../../../lib/app-language";
import { attachSessionStreamListeners } from "./stream-events";
import {
bookKey,
@@ -62,7 +63,8 @@ function formatAttachmentSize(size: number): string {
function formatUserMessageForDisplay(text: string, attachments: ReadonlyArray<ChatAttachmentPayload>): string {
if (attachments.length === 0) return text;
const lines = text ? [text, "", "附件:"] : ["附件:"];
const heading = tr("附件:", "Attachments:");
const lines = text ? [text, "", heading] : [heading];
for (const attachment of attachments) {
lines.push(`- ${attachment.filename} (${attachment.mediaType || "application/octet-stream"}, ${formatAttachmentSize(attachment.size)})`);
}
@@ -386,7 +388,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
const attachments = options?.attachments ?? [];
const session = get().sessions[sessionId];
if ((!trimmed && attachments.length === 0) || !session || session.isStreaming) return;
const userInstruction = trimmed || "请阅读我上传的文件。";
const userInstruction = trimmed || tr("请阅读我上传的文件。", "Please read the files I uploaded.");
const activeBookId = options?.activeBookId ?? session.bookId ?? undefined;
const sessionKind: ChatSessionKind = options?.sessionKind
?? session.sessionKind
@@ -396,7 +398,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
if (!get().selectedModel) {
get().addUserMessage(sessionId, formatUserMessageForDisplay(userInstruction, attachments));
get().addErrorMessage(sessionId, "请先选择一个模型");
get().addErrorMessage(sessionId, tr("请先选择一个模型", "Select a model first"));
return;
}
@@ -565,7 +567,10 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
if (hasStream) {
get().finalizeStream(sessionId, streamTs, "", toolCall);
} else {
const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
const emptyMessage = tr(
"模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。",
"The model returned no text. Check the protocol type (chat/responses), the streaming toggle, or upstream service compatibility.",
);
get().addErrorMessage(sessionId, emptyMessage);
}
}
@@ -8,33 +8,36 @@ import type {
ToolExecution,
} from "../../types";
import { localizeKnownRuntimeMessage } from "../../../../lib/error-copy";
import { tr } from "../../../../lib/app-language";
const NULL_BOOK_KEY = "__null__";
const AGENT_LABELS: Record<string, string> = {
architect: "建书",
writer: "写作",
auditor: "审计",
reviser: "修订",
exporter: "导出",
// [zh, en] tuples resolved through tr() at call time so labels follow the
// current app language instead of the language active at module load.
const AGENT_LABELS: Record<string, readonly [string, string]> = {
architect: ["建书", "Create book"],
writer: ["写作", "Write"],
auditor: ["审计", "Audit"],
reviser: ["修订", "Revise"],
exporter: ["导出", "Export"],
};
const TOOL_LABELS: Record<string, string> = {
read: "读取文件",
edit: "编辑文件",
grep: "搜索",
ls: "列目录",
context_compression: "整理上下文",
propose_action: "确认动作",
short_fiction_run: "短篇生产",
generate_cover: "生成封面",
script_create: "剧本创作",
storyboard_create: "分镜创作",
interactive_film_create: "互动影游",
play_edit: "编辑互动世界",
play_start: "启动互动世界",
play_revise: "重做互动回合",
play_step: "推进互动世界",
const TOOL_LABELS: Record<string, readonly [string, string]> = {
read: ["读取文件", "Read file"],
edit: ["编辑文件", "Edit file"],
grep: ["搜索", "Search"],
ls: ["列目录", "List directory"],
context_compression: ["整理上下文", "Organize context"],
propose_action: ["确认动作", "Confirm action"],
short_fiction_run: ["短篇生产", "Short fiction run"],
generate_cover: ["生成封面", "Generate cover"],
script_create: ["剧本创作", "Create script"],
storyboard_create: ["分镜创作", "Create storyboard"],
interactive_film_create: ["互动影游", "Interactive film"],
play_edit: ["编辑互动世界", "Edit interactive world"],
play_start: ["启动互动世界", "Start interactive world"],
play_revise: ["重做互动回合", "Redo play turn"],
play_step: ["推进互动世界", "Advance interactive world"],
};
export function bookKey(bookId: string | null | undefined): string {
@@ -47,8 +50,12 @@ export function extractErrorMessage(error: string | { code?: string; message?: s
}
export function resolveToolLabel(tool: string, agent?: string): string {
if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
return TOOL_LABELS[tool] ?? tool;
if (tool === "sub_agent" && agent) {
const label = AGENT_LABELS[agent];
return label ? tr(label[0], label[1]) : agent;
}
const label = TOOL_LABELS[tool];
return label ? tr(label[0], label[1]) : tool;
}
export function summarizeResult(result: unknown): string {
@@ -1,6 +1,7 @@
import type { StateCreator } from "zustand";
import type { ChatStore, MessageActions, MessagePart, PipelineStage, ToolExecution } from "../../types";
import { shouldRefreshSidebarForTool } from "../../message-policy";
import { tr } from "../../../../lib/app-language";
import {
deriveFlat,
extractToolDetails,
@@ -478,22 +479,24 @@ export function attachSessionStreamListeners({
}
function compressionLabel(category: ContextCompressionCategory): string {
return category === "session_context" ? "整理会话记忆" : "压缩故事上下文";
return category === "session_context"
? tr("整理会话记忆", "Organize session memory")
: tr("压缩故事上下文", "Compress story context");
}
function compressionSourceSummary(sources: readonly string[] | undefined): string {
if (!sources || sources.length === 0) return "";
const preview = sources.slice(0, 3).join(", ");
const suffix = sources.length > 3 ? ` +${sources.length - 3}` : "";
return `来源 ${sources.length}: ${preview}${suffix}`;
return `${tr("来源", "sources")} ${sources.length}: ${preview}${suffix}`;
}
function compressionProgress(data: ContextCompressionEventPayload): PipelineStage["progress"] | undefined {
if (data.phase !== "start") return undefined;
const parts = [
data.protectedTokens !== undefined ? `保护 ${data.protectedTokens}` : "",
data.compressibleTokens !== undefined ? `可压缩 ${data.compressibleTokens}` : "",
data.budgetTokens !== undefined ? `预算 ${data.budgetTokens}` : "",
data.protectedTokens !== undefined ? `${tr("保护", "protected")} ${data.protectedTokens}` : "",
data.compressibleTokens !== undefined ? `${tr("可压缩", "compressible")} ${data.compressibleTokens}` : "",
data.budgetTokens !== undefined ? `${tr("预算", "budget")} ${data.budgetTokens}` : "",
compressionSourceSummary(data.sources),
].filter(Boolean);
return {
@@ -537,7 +540,7 @@ function applyContextCompressionToParts(
running.stages = upsertCompressionStage(running.stages, category, phase, data);
if (phase === "error") {
running.status = "error";
running.error = data.message ?? `${compressionLabel(category)}失败`;
running.error = data.message ?? `${compressionLabel(category)}${tr("失败", " failed")}`;
}
return;
}
@@ -559,6 +562,6 @@ function applyContextCompressionToParts(
execution.label = compressionLabel(category);
execution.stages = upsertCompressionStage(execution.stages, category, phase, data);
if (phase !== "start") execution.completedAt = Date.now();
if (phase === "error") execution.error = data.message ?? `${compressionLabel(category)}失败`;
if (phase === "error") execution.error = data.message ?? `${compressionLabel(category)}${tr("失败", " failed")}`;
if (!existing) parts.push({ type: "tool", execution });
}