已合并
fix(web): 清除中文界面残留硬编码英文与内部术语(Issue #98) #149
hugoxk创建于 9月19日
fix(web): 清除中文界面残留硬编码英文与内部术语(Issue #98) #149
已合并
共 16 个文件变更+387-76
| @@ -33,6 +33,19 @@ const ACTIVITY_STATUS_KEYS: Record<string, MessageKey> = { | |||
| 33 | failed: "ideaResearch.activity.failed", | 33 | failed: "ideaResearch.activity.failed", |
| 34 | }; | 34 | }; |
| 35 | 35 | ||
| 36 | +const IDEA_TREE_STATUS_KEYS: Record<string, MessageKey> = { | ||
| 37 | + pending: "ideaTree.status.pending", | ||
| 38 | + running: "ideaTree.status.running", | ||
| 39 | + done: "ideaTree.status.done", | ||
| 40 | + needs_retry: "ideaTree.status.needs_retry", | ||
| 41 | + failed: "ideaTree.status.failed", | ||
| 42 | +}; | ||
| 43 | + | ||
| 44 | +const IDEA_TREE_SEARCH_STATUS_KEYS: Record<string, MessageKey> = { | ||
| 45 | + active: "ideaTree.search.active", | ||
| 46 | + pruned: "ideaTree.search.pruned", | ||
| 47 | +}; | ||
| 48 | + | ||
| 36 | /** An unknown value renders as itself: the engine may report a status this | 49 | /** An unknown value renders as itself: the engine may report a status this |
| 37 | * build does not know, and showing the raw token beats showing nothing. */ | 50 | * build does not know, and showing the raw token beats showing nothing. */ |
| 38 | export function ideaResearchStatusLabel(t: Translate, status: string): string { | 51 | export function ideaResearchStatusLabel(t: Translate, status: string): string { |
| @@ -45,6 +58,16 @@ export function ideaResearchActivityStatusLabel(t: Translate, status: string): s | |||
| 45 | return key ? t(key) : status; | 58 | return key ? t(key) : status; |
| 46 | } | 59 | } |
| 47 | 60 | ||
| 61 | +export function ideaTreeStatusLabel(t: Translate, status: string): string { | ||
| 62 | + const key = IDEA_TREE_STATUS_KEYS[status]; | ||
| 63 | + return key ? t(key) : status; | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +export function ideaTreeSearchStatusLabel(t: Translate, status: string): string { | ||
| 67 | + const key = IDEA_TREE_SEARCH_STATUS_KEYS[status]; | ||
| 68 | + return key ? t(key) : status; | ||
| 69 | +} | ||
| 70 | + | ||
| 48 | export function ideaResearchPhaseLabel(t: Translate, research: IdeaResearchState, role: string): string { | 71 | export function ideaResearchPhaseLabel(t: Translate, research: IdeaResearchState, role: string): string { |
| 49 | const key = ROLE_KEYS[role]; | 72 | const key = ROLE_KEYS[role]; |
| 50 | if (key) return t(key); | 73 | if (key) return t(key); |
| @@ -8,6 +8,7 @@ import type { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent } | |||
| 8 | 8 | ||
| 9 | import type { IdeaTreeGraph, IdeaTreeNode } from "@sciencediscovery/schema"; | 9 | import type { IdeaTreeGraph, IdeaTreeNode } from "@sciencediscovery/schema"; |
| 10 | 10 | ||
| 11 | +import { ideaTreeStatusLabel } from "./IdeaResearchLabels.js"; | ||
| 11 | import { useLocale } from "./i18n/index.js"; | 12 | import { useLocale } from "./i18n/index.js"; |
| 12 | 13 | ||
| 13 | const NODE_WIDTH = 244; | 14 | const NODE_WIDTH = 244; |
| @@ -297,11 +298,11 @@ export function IdeaTreeCanvas({ | |||
| 297 | x={NODE_WIDTH - 91} | 298 | x={NODE_WIDTH - 91} |
| 298 | y="10" | 299 | y="10" |
| 299 | /> | 300 | /> |
| 300 | - <text className="idea-tree-node-status" fill={IDEA_TREE_STATUS_COLORS[node.status]} textAnchor="middle" x={NODE_WIDTH - 52} y="24">{node.kind === "direction" ? t("ideaTree.direction") : node.status.replace("_", " ")}</text> | 301 | + <text className="idea-tree-node-status" fill={IDEA_TREE_STATUS_COLORS[node.status]} textAnchor="middle" x={NODE_WIDTH - 52} y="24">{node.kind === "direction" ? t("ideaTree.direction") : ideaTreeStatusLabel(t, node.status)}</text> |
| 301 | <foreignObject height="40" width={NODE_WIDTH - 40} x="20" y="38"> | 302 | <foreignObject height="40" width={NODE_WIDTH - 40} x="20" y="38"> |
| 302 | <div className="idea-tree-node-title" title={node.hypothesis}>{node.hypothesis}</div> | 303 | <div className="idea-tree-node-title" title={node.hypothesis}>{node.hypothesis}</div> |
| 303 | </foreignObject> | 304 | </foreignObject> |
| 304 | - <text className="idea-tree-node-meta" x="20" y="94">Depth {node.depth}{node.score === null ? "" : ` · Score ${node.score}`}</text> | 305 | + <text className="idea-tree-node-meta" x="20" y="94">{node.score === null ? t("ideaTree.canvas.depth", { depth: node.depth }) : t("ideaTree.canvas.depthScore", { depth: node.depth, score: node.score })}</text> |
| 305 | </g>; | 306 | </g>; |
| 306 | })} | 307 | })} |
| 307 | </g> | 308 | </g> |
| @@ -2,11 +2,21 @@ | |||
| 2 | // | 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | // you may not use this file except in compliance with the License. | 4 | // you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at | ||
| 6 | +// | ||
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | +// | ||
| 9 | +// Unless required by applicable law or agreed to in writing, software | ||
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | +// See the License for the specific language governing permissions and | ||
| 13 | +// limitations under the License. | ||
| 5 | 14 | ||
| 6 | import { useEffect, useMemo, useState, type ReactNode } from "react"; | 15 | import { useEffect, useMemo, useState, type ReactNode } from "react"; |
| 7 | 16 | ||
| 8 | import type { IdeaTreeGraph, IdeaTreeNode } from "@sciencediscovery/schema"; | 17 | import type { IdeaTreeGraph, IdeaTreeNode } from "@sciencediscovery/schema"; |
| 9 | 18 | ||
| 19 | +import { ideaTreeSearchStatusLabel, ideaTreeStatusLabel } from "./IdeaResearchLabels.js"; | ||
| 10 | import { useLocale, type MessageKey } from "./i18n/index.js"; | 20 | import { useLocale, type MessageKey } from "./i18n/index.js"; |
| 11 | import { CloseIcon } from "./icons.js"; | 21 | import { CloseIcon } from "./icons.js"; |
| 12 | import { IDEA_TREE_STATUS_COLORS, IdeaTreeCanvas } from "./IdeaTreeCanvas.js"; | 22 | import { IDEA_TREE_STATUS_COLORS, IdeaTreeCanvas } from "./IdeaTreeCanvas.js"; |
| @@ -32,46 +42,46 @@ function formatValue(value: unknown): string { | |||
| 32 | function IdeaTreeNodeDetail({ node, autonomous, onOpenArtifact, onOpenSubagent }: { node?: IdeaTreeNode; autonomous?: boolean; onOpenArtifact?: (id: string) => void; onOpenSubagent?: (id: string) => void }) { | 42 | function IdeaTreeNodeDetail({ node, autonomous, onOpenArtifact, onOpenSubagent }: { node?: IdeaTreeNode; autonomous?: boolean; onOpenArtifact?: (id: string) => void; onOpenSubagent?: (id: string) => void }) { |
| 33 | const { t } = useLocale(); | 43 | const { t } = useLocale(); |
| 34 | if (!node) return <div className="idea-tree-detail-empty"> | 44 | if (!node) return <div className="idea-tree-detail-empty"> |
| 35 | - <strong>Select a tree node</strong> | 45 | + <strong>{t("ideaTree.explorer.emptyTitle")}</strong> |
| 36 | - <p>Click a node to inspect its hypothesis, result, score, execution state, and artifacts.</p> | 46 | + <p>{t("ideaTree.explorer.emptyHelp")}</p> |
| 37 | </div>; | 47 | </div>; |
| 38 | const fields: Array<[string, unknown]> = autonomous ? [ | 48 | const fields: Array<[string, unknown]> = autonomous ? [ |
| 39 | [t("ideaTree.node.kind"), node.kind === "direction" ? t("ideaTree.kind.direction") : t("ideaTree.kind.candidate")], | 49 | [t("ideaTree.node.kind"), node.kind === "direction" ? t("ideaTree.kind.direction") : t("ideaTree.kind.candidate")], |
| 40 | [t("ideaTree.node.depth"), node.depth], [t("ideaTree.node.score"), node.score], [t("ideaTree.node.parent"), node.parentId], | 50 | [t("ideaTree.node.depth"), node.depth], [t("ideaTree.node.score"), node.score], [t("ideaTree.node.parent"), node.parentId], |
| 41 | [t("ideaTree.node.children"), node.childrenIds], | 51 | [t("ideaTree.node.children"), node.childrenIds], |
| 42 | ] : [ | 52 | ] : [ |
| 43 | - ["Status", node.status], | 53 | + [t("ideaTree.node.status"), ideaTreeStatusLabel(t, node.status)], |
| 44 | - ["Search", node.searchStatus], | 54 | + [t("ideaTree.node.search"), ideaTreeSearchStatusLabel(t, node.searchStatus)], |
| 45 | - ["Depth", node.depth], | 55 | + [t("ideaTree.node.depth"), node.depth], |
| 46 | - ["Priority", node.priority], | 56 | + [t("ideaTree.node.priority"), node.priority], |
| 47 | - ["Score", node.score], | 57 | + [t("ideaTree.node.score"), node.score], |
| 48 | - ["Attempts", node.attemptCount], | 58 | + [t("ideaTree.node.attempts"), node.attemptCount], |
| 49 | - ["Parent", node.parentId], | 59 | + [t("ideaTree.node.parent"), node.parentId], |
| 50 | - ["Children", node.childrenIds], | 60 | + [t("ideaTree.node.children"), node.childrenIds], |
| 51 | - ["Artifacts", node.artifactRefs], | 61 | + [t("ideaTree.node.artifacts"), node.artifactRefs], |
| 52 | - ["Active execution", node.activeExecutionId], | 62 | + [t("ideaTree.node.activeExecution"), node.activeExecutionId], |
| 53 | - ["Last execution", node.lastExecutionId], | 63 | + [t("ideaTree.node.lastExecution"), node.lastExecutionId], |
| 54 | - ["Result handle", node.completedResultHandle], | 64 | + [t("ideaTree.node.resultHandle"), node.completedResultHandle], |
| 55 | - ["Updated", node.updatedAt], | 65 | + [t("ideaTree.node.updated"), node.updatedAt], |
| 56 | ]; | 66 | ]; |
| 57 | return <article className="idea-tree-detail"> | 67 | return <article className="idea-tree-detail"> |
| 58 | <header> | 68 | <header> |
| 59 | <span className="idea-tree-detail-id">{node.id}</span> | 69 | <span className="idea-tree-detail-id">{node.id}</span> |
| 60 | - <span className="idea-tree-status" style={{ borderColor: IDEA_TREE_STATUS_COLORS[node.status], color: IDEA_TREE_STATUS_COLORS[node.status] }}>{node.kind === "direction" ? t("ideaTree.kind.direction") : node.status}</span> | 70 | + <span className="idea-tree-status" style={{ borderColor: IDEA_TREE_STATUS_COLORS[node.status], color: IDEA_TREE_STATUS_COLORS[node.status] }}>{node.kind === "direction" ? t("ideaTree.kind.direction") : ideaTreeStatusLabel(t, node.status)}</span> |
| 61 | </header> | 71 | </header> |
| 62 | <section> | 72 | <section> |
| 63 | - <h3>Hypothesis</h3> | 73 | + <h3>{t("ideaTree.section.hypothesis")}</h3> |
| 64 | <p>{node.hypothesis}</p> | 74 | <p>{node.hypothesis}</p> |
| 65 | </section> | 75 | </section> |
| 66 | - {node.insight ? <section><h3>Insight</h3><p>{node.insight}</p></section> : null} | 76 | + {node.insight ? <section><h3>{t("ideaTree.section.insight")}</h3><p>{node.insight}</p></section> : null} |
| 67 | - {!autonomous && node.result ? <section><h3>Result</h3><p>{node.result}</p></section> : null} | 77 | + {!autonomous && node.result ? <section><h3>{t("ideaTree.section.result")}</h3><p>{node.result}</p></section> : null} |
| 68 | {autonomous && Object.entries(node.stages ?? {}).map(([role, result]) => <section key={role}> | 78 | {autonomous && Object.entries(node.stages ?? {}).map(([role, result]) => <section key={role}> |
| 69 | <h3>{ASSESSOR_KEYS[role] ? t(ASSESSOR_KEYS[role]!) : role}{result.score === undefined ? "" : ` · ${result.score}`}</h3> | 79 | <h3>{ASSESSOR_KEYS[role] ? t(ASSESSOR_KEYS[role]!) : role}{result.score === undefined ? "" : ` · ${result.score}`}</h3> |
| 70 | <p>{result.text}</p> | 80 | <p>{result.text}</p> |
| 71 | </section>)} | 81 | </section>)} |
| 72 | - {node.pruneReason ? <section><h3>Prune reason</h3><p>{node.pruneReason}</p></section> : null} | 82 | + {node.pruneReason ? <section><h3>{t("ideaTree.section.pruneReason")}</h3><p>{node.pruneReason}</p></section> : null} |
| 73 | - <section>{node.artifactRefs.map(id => <button key={id} type="button" onClick={() => onOpenArtifact?.(id)}>Artifact {id}</button>)}</section> | 83 | + <section>{node.artifactRefs.map(id => <button key={id} type="button" onClick={() => onOpenArtifact?.(id)}>{t("ideaTree.explorer.artifactButton", { id })}</button>)}</section> |
| 74 | - <section>{node.subagentIds?.map((id, index) => <button key={id} type="button" onClick={() => onOpenSubagent?.(id)}>Subagent {index + 1}</button>)}</section> | 84 | + <section>{node.subagentIds?.map((id, index) => <button key={id} type="button" onClick={() => onOpenSubagent?.(id)}>{t("ideaTree.explorer.subagentButton", { index: index + 1 })}</button>)}</section> |
| 75 | <dl> | 85 | <dl> |
| 76 | {fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{formatValue(value)}</dd></div>)} | 86 | {fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{formatValue(value)}</dd></div>)} |
| 77 | </dl> | 87 | </dl> |
| @@ -99,6 +109,7 @@ export function IdeaTreeExplorer({ | |||
| 99 | onSelectTree: (treeId: string) => void; | 109 | onSelectTree: (treeId: string) => void; |
| 100 | treeIds: string[]; | 110 | treeIds: string[]; |
| 101 | }) { | 111 | }) { |
| 112 | + const { t } = useLocale(); | ||
| 102 | const [selectedId, setSelectedId] = useState<string>("ROOT"); | 113 | const [selectedId, setSelectedId] = useState<string>("ROOT"); |
| 103 | const [query, setQuery] = useState(""); | 114 | const [query, setQuery] = useState(""); |
| 104 | const [visibleStatuses, setVisibleStatuses] = useState<ReadonlySet<IdeaTreeNode["status"]>>(new Set(STATUSES)); | 115 | const [visibleStatuses, setVisibleStatuses] = useState<ReadonlySet<IdeaTreeNode["status"]>>(new Set(STATUSES)); |
| @@ -133,23 +144,23 @@ export function IdeaTreeExplorer({ | |||
| 133 | }; | 144 | }; |
| 134 | 145 | ||
| 135 | return <div className="idea-tree-explorer-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}> | 146 | return <div className="idea-tree-explorer-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}> |
| 136 | - <section aria-label="Idea Tree explorer" aria-modal={true} className="idea-tree-explorer-panel" role="dialog"> | 147 | + <section aria-label={t("ideaTree.explorer.aria")} aria-modal={true} className="idea-tree-explorer-panel" role="dialog"> |
| 137 | <header className="idea-tree-explorer-header"> | 148 | <header className="idea-tree-explorer-header"> |
| 138 | <div> | 149 | <div> |
| 139 | - <span className="eyebrow">Agent search</span> | 150 | + <span className="eyebrow">{t("ideaTree.explorer.eyebrow")}</span> |
| 140 | - <h2>Idea Tree</h2> | 151 | + <h2>{t("ideaTree.explorer.title")}</h2> |
| 141 | <p title={graph.objective}>{graph.objective}</p> | 152 | <p title={graph.objective}>{graph.objective}</p> |
| 142 | </div> | 153 | </div> |
| 143 | <div className="idea-tree-explorer-controls"> | 154 | <div className="idea-tree-explorer-controls"> |
| 144 | {treeIds.length > 1 ? <label> | 155 | {treeIds.length > 1 ? <label> |
| 145 | - <span>Tree</span> | 156 | + <span>{t("ideaTree.explorer.tree")}</span> |
| 146 | <select disabled={loading} onChange={(event) => onSelectTree(event.target.value)} value={graph.treeId}> | 157 | <select disabled={loading} onChange={(event) => onSelectTree(event.target.value)} value={graph.treeId}> |
| 147 | - {treeIds.map((treeId, index) => <option key={treeId} value={treeId}>{index === 0 ? "Latest · " : ""}{treeId}</option>)} | 158 | + {treeIds.map((treeId, index) => <option key={treeId} value={treeId}>{index === 0 ? t("ideaTree.explorer.latestTree") : ""}{treeId}</option>)} |
| 148 | </select> | 159 | </select> |
| 149 | </label> : null} | 160 | </label> : null} |
| 150 | - <span>{graph.nodes.length} nodes</span> | 161 | + <span>{t("ideaTree.explorer.nodeCount", { count: graph.nodes.length })}</span> |
| 151 | - {!autonomous && <span>revision {graph.revision}</span>} | 162 | + {!autonomous && <span>{t("ideaTree.explorer.revision", { revision: graph.revision })}</span>} |
| 152 | - <button aria-label="Close Idea Tree" className="icon-button" onClick={onClose} type="button"><CloseIcon size={20} /></button> | 163 | + <button aria-label={t("ideaTree.explorer.closeAria")} className="icon-button" onClick={onClose} type="button"><CloseIcon size={20} /></button> |
| 153 | </div> | 164 | </div> |
| 154 | </header> | 165 | </header> |
| 155 | {controls} | 166 | {controls} |
| @@ -158,17 +169,17 @@ export function IdeaTreeExplorer({ | |||
| 158 | <main className="idea-tree-explorer-graph"> | 169 | <main className="idea-tree-explorer-graph"> |
| 159 | <div className="idea-tree-toolbar"> | 170 | <div className="idea-tree-toolbar"> |
| 160 | <label className="idea-tree-search"> | 171 | <label className="idea-tree-search"> |
| 161 | - <span>Find</span> | 172 | + <span>{t("ideaTree.explorer.find")}</span> |
| 162 | - <input onChange={(event) => setQuery(event.target.value)} placeholder="Node id, hypothesis, insight…" value={query} /> | 173 | + <input onChange={(event) => setQuery(event.target.value)} placeholder={t("ideaTree.explorer.findPlaceholder")} value={query} /> |
| 163 | </label> | 174 | </label> |
| 164 | - <div className="idea-tree-filter-chips" aria-label="Filter node statuses"> | 175 | + <div className="idea-tree-filter-chips" aria-label={t("ideaTree.explorer.filterAria")}> |
| 165 | {STATUSES.filter((status) => statusCounts.has(status)).map((status) => <button | 176 | {STATUSES.filter((status) => statusCounts.has(status)).map((status) => <button |
| 166 | aria-pressed={visibleStatuses.has(status)} | 177 | aria-pressed={visibleStatuses.has(status)} |
| 167 | className={visibleStatuses.has(status) ? "active" : ""} | 178 | className={visibleStatuses.has(status) ? "active" : ""} |
| 168 | key={status} | 179 | key={status} |
| 169 | onClick={() => toggleStatus(status)} | 180 | onClick={() => toggleStatus(status)} |
| 170 | type="button" | 181 | type="button" |
| 171 | - ><i style={{ background: IDEA_TREE_STATUS_COLORS[status] }} />{status}<em>{statusCounts.get(status)}</em></button>)} | 182 | + ><i style={{ background: IDEA_TREE_STATUS_COLORS[status] }} />{ideaTreeStatusLabel(t, status)}<em>{statusCounts.get(status)}</em></button>)} |
| 172 | </div> | 183 | </div> |
| 173 | </div> | 184 | </div> |
| 174 | <div className="idea-tree-canvas-host"> | 185 | <div className="idea-tree-canvas-host"> |
| @@ -179,8 +190,8 @@ export function IdeaTreeExplorer({ | |||
| 179 | selectedId={selectedId} | 190 | selectedId={selectedId} |
| 180 | visibleStatuses={visibleStatuses} | 191 | visibleStatuses={visibleStatuses} |
| 181 | /> | 192 | /> |
| 182 | - {loading ? <div className="idea-tree-loading">Loading tree…</div> : null} | 193 | + {loading ? <div className="idea-tree-loading">{t("ideaTree.explorer.loading")}</div> : null} |
| 183 | - <p className="idea-tree-canvas-hint">Auto layout · click to inspect · drag background to pan · scroll to zoom</p> | 194 | + <p className="idea-tree-canvas-hint">{t("ideaTree.explorer.canvasHint")}</p> |
| 184 | </div> | 195 | </div> |
| 185 | </main> | 196 | </main> |
| 186 | </div> | 197 | </div> |
| @@ -729,7 +729,7 @@ export function ArtifactModal({ | |||
| 729 | setPoint(undefined); | 729 | setPoint(undefined); |
| 730 | setNote(""); | 730 | setNote(""); |
| 731 | } catch (error) { | 731 | } catch (error) { |
| 732 | - onError(error instanceof Error ? error : "Could not save annotation"); | 732 | + onError(error instanceof Error ? error : t("error.saveAnnotation")); |
| 733 | } | 733 | } |
| 734 | } | 734 | } |
| 735 | 735 | ||
| @@ -836,7 +836,7 @@ export function ArtifactModal({ | |||
| 836 | ? datasetPreview | 836 | ? datasetPreview |
| 837 | : artifact?.kind === "dataset" && jsonSource | 837 | : artifact?.kind === "dataset" && jsonSource |
| 838 | ? <JsonSourcePreview parsed={jsonSource.parsed} source={jsonSource.source} truncated={jsonSource.truncated} /> | 838 | ? <JsonSourcePreview parsed={jsonSource.parsed} source={jsonSource.source} truncated={jsonSource.truncated} /> |
| 839 | - : artifact?.kind === "dataset" && !(structureFormat && source) ? <pre className="artifact-source-preview">{source.slice(0, 100_000) || "Binary artifact: use the version content endpoint to inspect it."}</pre> : null} | 839 | + : artifact?.kind === "dataset" && !(structureFormat && source) ? <pre className="artifact-source-preview">{source.slice(0, 100_000) || t("artifact.binaryNote")}</pre> : null} |
| 840 | {/* JSON: a record array still gets the table (with a raw-JSON switch); | 840 | {/* JSON: a record array still gets the table (with a raw-JSON switch); |
| 841 | every other document is shown as formatted JSON or raw text, never | 841 | every other document is shown as formatted JSON or raw text, never |
| 842 | an empty grid. */} | 842 | an empty grid. */} |
| @@ -845,11 +845,11 @@ export function ArtifactModal({ | |||
| 845 | ? datasetPreview | 845 | ? datasetPreview |
| 846 | : jsonSource | 846 | : jsonSource |
| 847 | ? <JsonSourcePreview parsed={jsonSource.parsed} source={jsonSource.source} truncated={jsonSource.truncated} /> | 847 | ? <JsonSourcePreview parsed={jsonSource.parsed} source={jsonSource.source} truncated={jsonSource.truncated} /> |
| 848 | - : <pre className="artifact-source-preview">{source.slice(0, 100_000) || "Loading JSON content…"}</pre> | 848 | + : <pre className="artifact-source-preview">{source.slice(0, 100_000) || t("artifact.loadingJson")}</pre> |
| 849 | : null} | 849 | : null} |
| 850 | {artifact?.kind === "notebook" && preview?.kind === "notebook" && preview.mode === "notebook-cells" | 850 | {artifact?.kind === "notebook" && preview?.kind === "notebook" && preview.mode === "notebook-cells" |
| 851 | ? <NotebookCells cells={preview.cells} /> | 851 | ? <NotebookCells cells={preview.cells} /> |
| 852 | - : artifact?.kind === "notebook" && !(structureFormat && source) ? <pre className="artifact-source-preview">{source.slice(0, 100_000) || "Binary artifact: use the version content endpoint to inspect it."}</pre> : null} | 852 | + : artifact?.kind === "notebook" && !(structureFormat && source) ? <pre className="artifact-source-preview">{source.slice(0, 100_000) || t("artifact.binaryNote")}</pre> : null} |
| 853 | {/* "other" is where every source file lands — the kind enum has no | 853 | {/* "other" is where every source file lands — the kind enum has no |
| 854 | "code" — and it had no case at all, so a .py artifact opened to a | 854 | "code" — and it had no case at all, so a .py artifact opened to a |
| 855 | blank body. A NUL byte in the head is the binary tell; those get | 855 | blank body. A NUL byte in the head is the binary tell; those get |
| @@ -468,7 +468,9 @@ export class ArtifactsApiClient extends RunsApiClient { | |||
| 468 | ); | 468 | ); |
| 469 | if (!response.ok) { | 469 | if (!response.ok) { |
| 470 | this.reportAuthStatus(response.status); | 470 | this.reportAuthStatus(response.status); |
| 471 | - const reason = response.status === 404 ? "WebPage content not available" : "Could not load WebPage content"; | 471 | + const reason = response.status === 404 |
| 472 | + ? translateActive("error.webPageContentUnavailable") | ||
| 473 | + : translateActive("error.webPageContentLoadFailed"); | ||
| 472 | throw new ApiRequestError(reason, response.status); | 474 | throw new ApiRequestError(reason, response.status); |
| 473 | } | 475 | } |
| 474 | return await response.text(); | 476 | return await response.text(); |
| @@ -85,3 +85,53 @@ test("renders Chinese messages when the provider starts in zh-CN", () => { | |||
| 85 | const html = renderToStaticMarkup(createElement(LocaleProvider, { initialLocale: "zh-CN" }, createElement(Probe))); | 85 | const html = renderToStaticMarkup(createElement(LocaleProvider, { initialLocale: "zh-CN" }, createElement(Probe))); |
| 86 | assert.match(html, /zh-CN:系统设置/); | 86 | assert.match(html, /zh-CN:系统设置/); |
| 87 | }); | 87 | }); |
| 88 | + | ||
| 89 | +test("skill library copy never exposes internal recall or git-head jargon", () => { | ||
| 90 | + // The settings picker and the library cards are user-facing in both | ||
| 91 | + // locales; “M1” pipeline names and a git “Head” ref are implementation | ||
| 92 | + // detail the reader cannot act on (issue #98). | ||
| 93 | + for (const key of ["settings.skillLibrariesPickerHint", "settings.skillLibraryHead", "skillLibrary.head"] as const) { | ||
| 94 | + assert.ok(zhCN[key], `${key} needs a zh-CN message`); | ||
| 95 | + for (const locale of ["en", "zh-CN"] as const) { | ||
| 96 | + const message = translate(locale, key); | ||
| 97 | + assert.doesNotMatch(message, /M1|Head\s|\bHead\b/i, `${locale} ${key} leaks internal jargon: ${message}`); | ||
| 98 | + } | ||
| 99 | + } | ||
| 100 | + assert.equal(translate("en", "settings.skillLibraryHead", { version: "8a87052b" }), "Latest version 8a87052b"); | ||
| 101 | + assert.equal(translate("zh-CN", "settings.skillLibraryHead", { version: "8a87052b" }), "最新版本 8a87052b"); | ||
| 102 | +}); | ||
| 103 | + | ||
| 104 | +test("the zh-CN catalogue covers every locale-sensitive chrome string this fix touched", () => { | ||
| 105 | + const keys = [ | ||
| 106 | + "settings.loadingProxies", | ||
| 107 | + "error.saveIdeaTree", | ||
| 108 | + "error.saveAnnotation", | ||
| 109 | + "error.webPageContentUnavailable", | ||
| 110 | + "error.webPageContentLoadFailed", | ||
| 111 | + "timeline.replayTextTruncated", | ||
| 112 | + "timeline.replayStepTruncated", | ||
| 113 | + "timeline.historyTruncated", | ||
| 114 | + "timeline.runStatusFallback", | ||
| 115 | + "ideaTree.view.loadError", | ||
| 116 | + "ideaTree.view.retry", | ||
| 117 | + "ideaTree.view.stats", | ||
| 118 | + "ideaTree.view.running", | ||
| 119 | + "ideaTree.canvas.depth", | ||
| 120 | + "ideaTree.canvas.depthScore", | ||
| 121 | + "ideaTree.search.active", | ||
| 122 | + "ideaTree.search.pruned", | ||
| 123 | + "artifact.loadingJson", | ||
| 124 | + "reviewer.sourceIssuesAria", | ||
| 125 | + "usage.runLabel", | ||
| 126 | + "usage.standaloneInvocations", | ||
| 127 | + ] as const; | ||
| 128 | + for (const key of keys) { | ||
| 129 | + assert.ok(zhCN[key], `${key} needs a zh-CN message`); | ||
| 130 | + } | ||
| 131 | +}); | ||
| 132 | + | ||
| 133 | +test("idea tree statuses render as words instead of raw enum tokens", () => { | ||
| 134 | + assert.equal(translate("en", "ideaTree.status.needs_retry"), "Needs retry"); | ||
| 135 | + assert.equal(translate("zh-CN", "ideaTree.status.needs_retry"), "需重试"); | ||
| 136 | + assert.equal(translate("zh-CN", "ideaTree.status.running"), "执行中"); | ||
| 137 | +}); | ||
| @@ -0,0 +1,53 @@ | |||
| 1 | +// Copyright (C) 2026-2026 Huawei Technologies Co., Ltd | ||
| 2 | +// | ||
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 4 | +// you may not use this file except in compliance with the License. | ||
| 5 | +// You may obtain a copy of the License at | ||
| 6 | +// | ||
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | +// | ||
| 9 | +// Unless required by applicable law or agreed to in writing, software | ||
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | +// See the License for the specific language governing permissions and | ||
| 13 | +// limitations under the License. | ||
| 14 | + | ||
| 15 | +import assert from "node:assert/strict"; | ||
| 16 | +import test from "node:test"; | ||
| 17 | + | ||
| 18 | +import { zhCN } from "../src/i18n/messages.js"; | ||
| 19 | +import { setActiveLocale } from "../src/i18n/locale.js"; | ||
| 20 | +import { formatRunFailure } from "../src/run-failure.js"; | ||
| 21 | + | ||
| 22 | +test("a failure class reads as a plain cause with a recovery action, then the detail", () => { | ||
| 23 | + setActiveLocale("en"); | ||
| 24 | + assert.match( | ||
| 25 | + formatRunFailure("rate-limited", "429 Too Many Requests"), | ||
| 26 | + /^The model service is limiting requests\. Wait a moment and try again\. · 429 Too Many Requests$/, | ||
| 27 | + ); | ||
| 28 | + assert.match( | ||
| 29 | + formatRunFailure("transport-error", "fetch failed"), | ||
| 30 | + /^The model service could not be reached\./, | ||
| 31 | + ); | ||
| 32 | +}); | ||
| 33 | + | ||
| 34 | +test("the Chinese locale renders the plain cause in Chinese", () => { | ||
| 35 | + setActiveLocale("zh-CN"); | ||
| 36 | + assert.match( | ||
| 37 | + formatRunFailure("unauthorized", "401 invalid api key"), | ||
| 38 | + /^模型服务拒绝了当前凭据,请检查模型注册表中的 API Key 后重试。 · 401 invalid api key$/, | ||
| 39 | + ); | ||
| 40 | +}); | ||
| 41 | + | ||
| 42 | +test("every stable failure class has a zh-CN message", () => { | ||
| 43 | + for (const code of ["rate-limited", "semantic-error", "server-error", "timeout", "transport-error", "unauthorized"] as const) { | ||
| 44 | + const key = `runFailure.${code}` as const; | ||
| 45 | + assert.ok(zhCN[key], `${key} needs a zh-CN message`); | ||
| 46 | + } | ||
| 47 | +}); | ||
| 48 | + | ||
| 49 | +test("a failure without a class degrades to the original error text", () => { | ||
| 50 | + setActiveLocale("en"); | ||
| 51 | + assert.equal(formatRunFailure(undefined, "Gateway is unavailable"), "Gateway is unavailable"); | ||
| 52 | + assert.equal(formatRunFailure("timeout", " "), "The request timed out. Retry, or split the task / raise the timeout budget."); | ||
| 53 | +}); | ||
| @@ -256,7 +256,7 @@ test("renders skill library cards with pinned head version metadata", async () = | |||
| 256 | 256 | ||
| 257 | const text = JSON.stringify(renderer!.toJSON()).replace(/","/g, ""); | 257 | const text = JSON.stringify(renderer!.toJSON()).replace(/","/g, ""); |
| 258 | assert.match(text, /Evaluation Skills/); | 258 | assert.match(text, /Evaluation Skills/); |
| 259 | - assert.match(text, /Head version/); | 259 | + assert.match(text, /Latest version/); |
| 260 | assert.match(text, /2 skills/); | 260 | assert.match(text, /2 skills/); |
| 261 | assert.match(text, /abcdef123456/); | 261 | assert.match(text, /abcdef123456/); |
| 262 | assert.match(text, /Pending proposals/); | 262 | assert.match(text, /Pending proposals/); |