mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-30 17:22:02 +08:00
feat(studio): bilingual chat dynamic content
新增 app-language 全局语言工具(tr/getAppLanguage,非 React 模块用); 工具执行块、工具状态、agent/工具徽标、聊天错误提示、生成物抽屉、 error-copy 全部双语。默认 zh,现有断言不变
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user