diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6de88c8..b4630ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0", "dependencies": { "axios": "^1.7.9", + "i18next": "^26.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-force-graph-2d": "^1.25.4", + "react-i18next": "^17.0.2", "react-router-dom": "^7.1.1" }, "devDependencies": { @@ -272,6 +274,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2297,6 +2308,46 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/index-array-by": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", @@ -2916,6 +2967,33 @@ "react": "*" } }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -3319,7 +3397,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3360,6 +3438,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3473,6 +3560,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ef1907d..2005cf7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,11 @@ }, "dependencies": { "axios": "^1.7.9", + "i18next": "^26.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-force-graph-2d": "^1.25.4", + "react-i18next": "^17.0.2", "react-router-dom": "^7.1.1" }, "devDependencies": { diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index c50f737..855d55c 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; +import i18n from '../i18n'; interface Props { children: ReactNode; @@ -21,7 +22,6 @@ export class ErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // Log error to reporting service if (import.meta.env.DEV) { console.error('Error caught by boundary:', error, errorInfo); } @@ -29,25 +29,26 @@ export class ErrorBoundary extends Component { render() { if (this.state.hasError) { + const t = i18n.t.bind(i18n); return ( this.props.fallback || (

- Something went wrong + {t('errorBoundary.title')}

- An unexpected error occurred + {t('errorBoundary.message')}

{import.meta.env.DEV && this.state.error && (
- Error details + {t('errorBoundary.details')}
                     {this.state.error.stack}
                   
diff --git a/frontend/src/components/skill-detail/DiffViewer.tsx b/frontend/src/components/skill-detail/DiffViewer.tsx index 3a37b65..49b37b9 100644 --- a/frontend/src/components/skill-detail/DiffViewer.tsx +++ b/frontend/src/components/skill-detail/DiffViewer.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, type KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import type { DiffFile, DiffLine } from '../../utils/diffParser'; interface DiffViewerProps { @@ -124,6 +125,7 @@ function buildSplitRows(lines: DiffLine[], header: string): SplitDiffRow[] { } export default function DiffViewer({ files }: DiffViewerProps) { + const { t } = useTranslation(); const [selectedIndex, setSelectedIndex] = useState(0); const renderableFiles = useMemo( () => files.filter((file) => file.hunks.some((hunk) => hunk.lines.length > 0)), @@ -135,7 +137,7 @@ export default function DiffViewer({ files }: DiffViewerProps) { }, [renderableFiles]); if (renderableFiles.length === 0) { - return

No files in diff

; + return

{t('diffViewer.noFiles')}

; } const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0; @@ -216,8 +218,8 @@ export default function DiffViewer({ files }: DiffViewerProps) { return (
-
Old
-
New
+
{t('diffViewer.old')}
+
{t('diffViewer.new')}
{hunk.header} diff --git a/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx b/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx index 689749c..7675e7f 100644 --- a/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx +++ b/frontend/src/components/skill-detail/SkillEvolutionGraph.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import ForceGraph2D from 'react-force-graph-2d'; import type { SkillGraphNode } from '../../hooks/useSkillEvolutionGraphData'; @@ -25,6 +26,7 @@ export default function SkillEvolutionGraph({ onNodeClick, onBackgroundClick, }: SkillEvolutionGraphProps) { + const { t } = useTranslation(); const graphContainerRef = useRef(null); const fgRef = useRef(null); const [graphDim, setGraphDim] = useState({ width: 0, height: 0 }); @@ -180,7 +182,7 @@ export default function SkillEvolutionGraph({ }, []); if (graphData.nodes.length === 0) { - return
No lineage graph data.
; + return
{t('graph.noGraphData')}
; } return ( @@ -197,9 +199,9 @@ export default function SkillEvolutionGraph({ const graphNode = node as SkillGraphNode; return [ graphNode.name, - `score: ${graphNode.score.toFixed(1)}`, - `generation: ${graphNode.generation}`, - `origin: ${graphNode.origin}`, + t('graph.tooltipScore', { value: graphNode.score.toFixed(1) }), + t('graph.tooltipGeneration', { value: graphNode.generation }), + t('graph.tooltipOrigin', { value: graphNode.origin }), ].join('\n'); }} onNodeClick={(node) => { diff --git a/frontend/src/components/skill-detail/SkillVersionDrawer.tsx b/frontend/src/components/skill-detail/SkillVersionDrawer.tsx index b842b92..363f560 100644 --- a/frontend/src/components/skill-detail/SkillVersionDrawer.tsx +++ b/frontend/src/components/skill-detail/SkillVersionDrawer.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import type { SkillDetail } from '../../api'; import { parseDiff } from '../../utils/diffParser'; import EmptyState from '../EmptyState'; @@ -72,6 +73,7 @@ function lockScroll() { } export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVersionDrawerProps) { + const { t } = useTranslation(); const closeButtonRef = useRef(null); const rawDiff = skill?.lineage.content_diff ?? ''; @@ -135,16 +137,16 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
-

Skill Version

+

{t('drawer.skillVersion')}

{skill.name}

{skill.skill_id}

- Open as main + {t('drawer.openAsMain')}
@@ -153,13 +155,13 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
-
Version Summary
-
{skill.description || 'No description available for this version.'}
+
{t('drawer.versionSummary')}
+
{skill.description || t('drawer.noDescriptionAvailable')}
{skill.category} {skill.origin} - gen {skill.generation} - {skill.is_active ? 'active' : 'inactive'} + {t('drawer.gen', { generation: skill.generation })} + {skill.is_active ? t('common.active') : t('common.inactive')} {skill.tags.map((tag) => ( {tag} ))} @@ -167,67 +169,67 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{skill.score.toFixed(1)}
-
version score
+
{t('drawer.versionScore')}
-
Metrics
-

Execution quality

+
{t('drawer.metrics')}
+

{t('drawer.executionQuality')}

- - - - + + + +
-
Selections
{skill.total_selections}
-
Applied
{skill.total_applied}
-
Completions
{skill.total_completions}
-
Fallbacks
{skill.total_fallbacks}
+
{t('drawer.selectionsLabel')}
{skill.total_selections}
+
{t('drawer.appliedLabel')}
{skill.total_applied}
+
{t('drawer.completionsLabel')}
{skill.total_completions}
+
{t('drawer.fallbacksLabel')}
{skill.total_fallbacks}
-

Version Metadata

-

Origin: {skill.origin}

-

Generation: {skill.generation}

-

Visibility: {skill.visibility}

-

Created: {formatDate(skill.lineage.created_at)}

-

First seen: {formatDate(skill.first_seen)}

-

Last updated: {formatDate(skill.last_updated)}

-

Skill path: {skill.path || 'Unavailable'}

-

Skill dir: {skill.skill_dir || 'Unavailable'}

-

Parent IDs: {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : 'None'}

-

Change summary: {skill.lineage.change_summary || 'None'}

-

Effective score: {formatPercent(skill.effective_rate)}

+

{t('drawer.versionMetadata')}

+

{t('drawer.origin')} {skill.origin}

+

{t('drawer.generation')} {skill.generation}

+

{t('drawer.visibility')} {skill.visibility}

+

{t('drawer.created')} {formatDate(skill.lineage.created_at)}

+

{t('drawer.firstSeen')} {formatDate(skill.first_seen)}

+

{t('drawer.lastUpdated')} {formatDate(skill.last_updated)}

+

{t('drawer.skillPath')} {skill.path || t('common.unavailable')}

+

{t('drawer.skillDir')} {skill.skill_dir || t('common.unavailable')}

+

{t('drawer.parentIds')} {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : t('common.none')}

+

{t('drawer.changeSummary')} {skill.lineage.change_summary || t('common.none')}

+

{t('drawer.effectiveScore')} {formatPercent(skill.effective_rate)}

-
Diff
-

Content diff

+
{t('drawer.diff')}
+

{t('drawer.contentDiff')}

{isOversizedDiff ? ( - + ) : canShowDiff ? ( diffFiles.length > 0 ? ( ) : ( - + ) ) : ( - + )}
-
Source
-

SKILL.md preview

+
{t('drawer.source')}
+

{t('drawer.skillMdPreview')}

{sourcePreview ? (
@@ -235,14 +237,14 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{sourcePreview.content}
) : ( - + )}
-
Analyses
-

Recent execution analyses

+
{t('drawer.analyses')}
+

{t('drawer.recentAnalyses')}

{skill.recent_analyses.length > 0 ? (
@@ -252,15 +254,19 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
{analysis.task_id}
{formatDate(analysis.timestamp)}
-
{truncate(analysis.execution_note || 'No execution note', 220)}
+
{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}
- completed: {analysis.task_completed ? 'yes' : 'no'} · tool issues: {analysis.tool_issues.length} · suggestions: {analysis.evolution_suggestions.length} + {t('drawer.analysisCompleted', { + value: analysis.task_completed ? t('common.yes') : t('common.no'), + toolIssues: analysis.tool_issues.length, + suggestions: analysis.evolution_suggestions.length, + })}
))}
) : ( - + )} diff --git a/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx b/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx index 381b3a2..02aaddd 100644 --- a/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx +++ b/frontend/src/components/skill-detail/SkillVersionFilterBar.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + interface SkillVersionFilterBarProps { originFilter: string; onOriginFilterChange: (value: string) => void; @@ -15,16 +17,18 @@ export default function SkillVersionFilterBar({ allOrigins, allTags, }: SkillVersionFilterBarProps) { + const { t } = useTranslation(); + return (
- + onTagFilterChange(event.target.value)} className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm" > - + {allTags.map((tag) => ( ))} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json new file mode 100644 index 0000000..5d476a3 --- /dev/null +++ b/frontend/src/i18n/en.json @@ -0,0 +1,253 @@ +{ + "nav": { + "dashboard": "Dashboard", + "skills": "Skills", + "workflows": "Workflows" + }, + "common": { + "loading": "Loading…", + "yes": "yes", + "no": "no", + "none": "None", + "unavailable": "Unavailable", + "unknown": "Unknown", + "close": "Close", + "score": "score", + "success": "success", + "active": "active", + "inactive": "inactive", + "noDescription": "No description", + "steps_one": "{{count}} step", + "steps_other": "{{count}} steps", + "agentActions_one": "{{count}} agent action", + "agentActions_other": "{{count}} agent actions", + "tags": "+{{count}} tags" + }, + "langSwitch": { + "label": "Language" + }, + "errorBoundary": { + "title": "Something went wrong", + "message": "An unexpected error occurred", + "goToDashboard": "Go to Dashboard", + "details": "Error details" + }, + "dashboard": { + "title": "Dashboard", + "loadingDashboard": "Loading dashboard…", + "failedToLoad": "Failed to load overview", + "dashboardUnavailable": "Dashboard unavailable", + "totalSkills": "Total Skills", + "activeHint": "Active: {{count}}", + "avgSkillScore": "Average Skill Score", + "avgScoreHint": "Primary metric = effective rate × 100", + "workflowSessions": "Workflow Sessions", + "localRepo": "local repo", + "workspace": "workspace", + "recordedUnder": "Recorded under {{location}}", + "workflowSuccess": "Workflow Success", + "avgSuccessHint": "Average session success rate", + "health": "Health", + "runtimeSnapshot": "Runtime snapshot", + "status": "Status", + "dbPath": "DB Path", + "workflowCount": "Workflow Count", + "builtFrontend": "Built Frontend", + "skillsSection": "Skills", + "topScoredSkills": "Top scored skills", + "noSkillsYet": "No skills yet", + "noSkillsDesc": "Run OpenSpace tasks or sync skills into the local registry first.", + "effective": "effective {{value}}", + "applied": "applied {{value}}", + "selections": "selections {{count}}", + "workflowsSection": "Workflows", + "recentSessions": "Recent sessions", + "noWorkflowSessions": "No workflow sessions", + "noWorkflowDesc": "Recordings will appear after a task is executed with recording enabled." + }, + "skills": { + "title": "Skill classes", + "searchPlaceholder": "Search by name, id, description, tag, or origin", + "sortByScore": "Sort by best score", + "sortByUpdated": "Sort by updated time", + "sortByName": "Sort by name", + "skillClasses": "Skill Classes", + "versionsHint": "Versions: {{count}}", + "activeVersions": "Active Versions", + "withActivity": "With activity: {{count}}", + "avgBestScore": "Average Best Score", + "bestNodeScoreHint": "Best node score per class", + "selections": "Selections", + "completionsHint": "Completions: {{count}}", + "loadingSkills": "Loading skills…", + "failedToLoad": "Failed to load skills", + "noSkillsMatch": "No skills match", + "noSkillsMatchDesc": "Try another keyword, or execute tasks so new skill telemetry lands in SQLite.", + "bestScore": "best score", + "noClassDescription": "No class description", + "versions": "{{count}} versions", + "active": "{{count}} active", + "selectionsCount": "{{count}} selections" + }, + "skillDetail": { + "loadingDetail": "Loading skill detail…", + "failedToLoad": "Failed to load skill class", + "skillNotFound": "Skill not found", + "backToSkills": "← Back to Skills", + "anchoredOn": "Skill class anchored on {{id}}", + "skillClass": "Skill Class", + "evolutionOverview": "Evolution overview", + "activeTip": "active tip", + "inactiveAnchor": "inactive anchor", + "bestVersionScore": "best version score", + "skillDirectory": "Skill directory", + "latestVersionCreated": "Latest version created", + "representativeVersion": "Representative version", + "representativeUpdate": "Representative update", + "versions": "Versions", + "maxGeneration": "Max generation {{count}}", + "activeVersions": "Active Versions", + "originsCount": "Origins: {{count}}", + "averageScore": "Average Score", + "acrossAllVersions": "Across all versions in this lineage", + "selections": "Selections", + "representativeScore": "Representative score {{score}}", + "evolutionGraph": "Evolution Graph", + "versionLineage": "Version lineage", + "loadingDrawer": "Loading version drawer…", + "noLineageGraph": "No lineage graph", + "noLineageGraphDesc": "This skill does not yet have lineage data to visualize." + }, + "workflows": { + "title": "Recorded sessions", + "searchPlaceholder": "Search by task name or instruction", + "workflowSessions": "Workflow Sessions", + "scannedFrom": "Scanned from logs/recordings and logs/trajectories", + "averageSuccess": "Average Success", + "meanSuccessRate": "Mean success rate across sessions", + "loadingWorkflows": "Loading workflows…", + "failedToLoad": "Failed to load workflows", + "noSessions": "No workflow sessions", + "noSessionsDesc": "Run `openspace` with recording enabled, then refresh this page.", + "more": "+{{count}} more" + }, + "workflowDetail": { + "loadingDetail": "Loading workflow detail…", + "failedToLoad": "Failed to load workflow", + "workflowNotFound": "Workflow not found", + "backToWorkflows": "← Back to Workflows", + "workflowDetail": "Workflow detail", + "skillsSelected_one": "{{count}} skill selected", + "skillsSelected_other": "{{count}} skills selected", + "mergedEvent_one": "{{count}} merged event", + "mergedEvent_other": "{{count}} merged events", + "runDescription": "{{status}} run with {{iterations}} and {{actions}}.", + "started": "Started", + "duration": "Duration", + "stepsLabel": "Steps", + "latestEvent": "Latest event", + "successRate": "Success rate", + "successRateHint_one": "{{count}} successful iteration out of {{iterations}}", + "successRateHint_other": "{{count}} successful iterations out of {{iterations}}", + "iterations": "Iterations", + "totalRuntime": "{{duration}} total runtime", + "activeBackends": "Active backends", + "mostActive": "Most active {{backend}} · {{count}} events", + "noRecordedActivity": "No recorded tool activity", + "timelineEvents": "Timeline events", + "agentToolHint": "{{agentCount}} agent actions · {{toolCount}} tool events", + "noTimelineData": "No timeline data", + "noTimelineDesc": "This session does not yet contain trajectory or agent action records.", + "rawEventJson": "Raw event JSON", + "selection": "Selection", + "selectedSkills": "Selected skills", + "noSelectedSkills": "No selected skills", + "noSelectedSkillsDesc": "No skills were selected or recorded for this run.", + "session": "Session", + "overview": "Overview", + "taskId": "Task ID", + "runtime": "Runtime", + "window": "Window", + "ended": "Ended {{date}}", + "selectionMethod": "Selection method", + "notRecorded": "Not recorded", + "enabledBackends": "Enabled backends", + "backendActivity": "Backend activity", + "iteration_one": "{{count}} iteration", + "iteration_other": "{{count}} iterations", + "step_one": "{{count}} step", + "step_other": "{{count}} steps", + "agentAction_one": "{{count}} agent action", + "agentAction_other": "{{count}} agent actions", + "successfulIteration_one": "{{count}} successful iteration", + "successfulIteration_other": "{{count}} successful iterations" + }, + "drawer": { + "skillVersion": "Skill Version", + "openAsMain": "Open as main", + "versionSummary": "Version Summary", + "noDescriptionAvailable": "No description available for this version.", + "gen": "gen {{generation}}", + "versionScore": "version score", + "metrics": "Metrics", + "executionQuality": "Execution quality", + "effectiveRate": "Effective rate", + "completionRate": "Completion rate", + "appliedRate": "Applied rate", + "fallbackRate": "Fallback rate", + "selectionsLabel": "Selections", + "appliedLabel": "Applied", + "completionsLabel": "Completions", + "fallbacksLabel": "Fallbacks", + "versionMetadata": "Version Metadata", + "origin": "Origin:", + "generation": "Generation:", + "visibility": "Visibility:", + "created": "Created:", + "firstSeen": "First seen:", + "lastUpdated": "Last updated:", + "skillPath": "Skill path:", + "skillDir": "Skill dir:", + "parentIds": "Parent IDs:", + "changeSummary": "Change summary:", + "effectiveScore": "Effective score:", + "diff": "Diff", + "contentDiff": "Content diff", + "diffTooLarge": "Diff too large", + "diffTooLargeDesc": "This version has a very large content diff, so the inline viewer is disabled.", + "diffUnavailable": "Diff unavailable", + "diffUnavailableDesc": "This version has a diff payload, but it could not be parsed as a unified diff.", + "noContentDiff": "No content diff", + "noContentDiffDesc": "This version does not have a stored content diff.", + "source": "Source", + "skillMdPreview": "SKILL.md preview", + "sourceUnavailable": "Source unavailable", + "sourceUnavailableDesc": "This version points to a missing or unreadable SKILL.md path.", + "analyses": "Analyses", + "recentAnalyses": "Recent execution analyses", + "noExecutionNote": "No execution note", + "analysisCompleted": "completed: {{value}} · tool issues: {{toolIssues}} · suggestions: {{suggestions}}", + "noAnalysesYet": "No analyses yet", + "noAnalysesDesc": "Execution analyses will appear after recorded task runs are persisted into SQLite." + }, + "filter": { + "origin": "Origin:", + "allOrigins": "All Origins", + "tags": "Tags:", + "allTags": "All Tags" + }, + "graph": { + "noGraphData": "No lineage graph data.", + "tooltipScore": "score: {{value}}", + "tooltipGeneration": "generation: {{value}}", + "tooltipOrigin": "origin: {{value}}" + }, + "diffViewer": { + "noFiles": "No files in diff", + "old": "Old", + "new": "New" + }, + "format": { + "noInstruction": "No instruction captured" + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..d27eb6b --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,34 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import en from './en.json'; +import zh from './zh.json'; + +const STORAGE_KEY = 'openspace-lang'; + +function getSavedLanguage(): string { + try { + return localStorage.getItem(STORAGE_KEY) || 'en'; + } catch { + return 'en'; + } +} + +i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + zh: { translation: zh }, + }, + lng: getSavedLanguage(), + fallbackLng: 'en', + interpolation: { escapeValue: false }, +}); + +i18n.on('languageChanged', (lng) => { + try { + localStorage.setItem(STORAGE_KEY, lng); + } catch { + // ignore + } +}); + +export default i18n; diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json new file mode 100644 index 0000000..ec66e22 --- /dev/null +++ b/frontend/src/i18n/zh.json @@ -0,0 +1,253 @@ +{ + "nav": { + "dashboard": "仪表盘", + "skills": "技能", + "workflows": "工作流" + }, + "common": { + "loading": "加载中…", + "yes": "是", + "no": "否", + "none": "无", + "unavailable": "不可用", + "unknown": "未知", + "close": "关闭", + "score": "评分", + "success": "成功率", + "active": "活跃", + "inactive": "未激活", + "noDescription": "暂无描述", + "steps_one": "{{count}} 步", + "steps_other": "{{count}} 步", + "agentActions_one": "{{count}} 个智能体操作", + "agentActions_other": "{{count}} 个智能体操作", + "tags": "+{{count}} 个标签" + }, + "langSwitch": { + "label": "语言" + }, + "errorBoundary": { + "title": "出现了错误", + "message": "发生了意外错误", + "goToDashboard": "返回仪表盘", + "details": "错误详情" + }, + "dashboard": { + "title": "仪表盘", + "loadingDashboard": "正在加载仪表盘…", + "failedToLoad": "加载概览失败", + "dashboardUnavailable": "仪表盘不可用", + "totalSkills": "技能总数", + "activeHint": "活跃:{{count}}", + "avgSkillScore": "平均技能评分", + "avgScoreHint": "主要指标 = 有效率 × 100", + "workflowSessions": "工作流会话", + "localRepo": "本地仓库", + "workspace": "工作区", + "recordedUnder": "记录于{{location}}", + "workflowSuccess": "工作流成功率", + "avgSuccessHint": "平均会话成功率", + "health": "健康状态", + "runtimeSnapshot": "运行时快照", + "status": "状态", + "dbPath": "数据库路径", + "workflowCount": "工作流数量", + "builtFrontend": "前端构建", + "skillsSection": "技能", + "topScoredSkills": "评分最高的技能", + "noSkillsYet": "暂无技能", + "noSkillsDesc": "先运行 OpenSpace 任务或将技能同步到本地注册表中。", + "effective": "有效率 {{value}}", + "applied": "应用率 {{value}}", + "selections": "选择次数 {{count}}", + "workflowsSection": "工作流", + "recentSessions": "近期会话", + "noWorkflowSessions": "暂无工作流会话", + "noWorkflowDesc": "启用录制后执行任务,录制数据将在此显示。" + }, + "skills": { + "title": "技能类别", + "searchPlaceholder": "按名称、ID、描述、标签或来源搜索", + "sortByScore": "按最佳评分排序", + "sortByUpdated": "按更新时间排序", + "sortByName": "按名称排序", + "skillClasses": "技能类别", + "versionsHint": "版本数:{{count}}", + "activeVersions": "活跃版本", + "withActivity": "有活动记录:{{count}}", + "avgBestScore": "平均最佳评分", + "bestNodeScoreHint": "每个类别的最佳节点评分", + "selections": "选择次数", + "completionsHint": "完成次数:{{count}}", + "loadingSkills": "正在加载技能…", + "failedToLoad": "加载技能失败", + "noSkillsMatch": "没有匹配的技能", + "noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的技能遥测数据写入 SQLite。", + "bestScore": "最佳评分", + "noClassDescription": "暂无类别描述", + "versions": "{{count}} 个版本", + "active": "{{count}} 个活跃", + "selectionsCount": "{{count}} 次选择" + }, + "skillDetail": { + "loadingDetail": "正在加载技能详情…", + "failedToLoad": "加载技能类别失败", + "skillNotFound": "未找到技能", + "backToSkills": "← 返回技能列表", + "anchoredOn": "技能类别锚定于 {{id}}", + "skillClass": "技能类别", + "evolutionOverview": "演化概览", + "activeTip": "活跃端点", + "inactiveAnchor": "未激活锚点", + "bestVersionScore": "最佳版本评分", + "skillDirectory": "技能目录", + "latestVersionCreated": "最新版本创建时间", + "representativeVersion": "代表版本", + "representativeUpdate": "代表版本更新时间", + "versions": "版本数", + "maxGeneration": "最大世代 {{count}}", + "activeVersions": "活跃版本", + "originsCount": "来源数:{{count}}", + "averageScore": "平均评分", + "acrossAllVersions": "该谱系中所有版本的平均值", + "selections": "选择次数", + "representativeScore": "代表评分 {{score}}", + "evolutionGraph": "演化图", + "versionLineage": "版本谱系", + "loadingDrawer": "正在加载版本抽屉…", + "noLineageGraph": "暂无谱系图", + "noLineageGraphDesc": "该技能尚无可供可视化的谱系数据。" + }, + "workflows": { + "title": "录制会话", + "searchPlaceholder": "按任务名称或指令搜索", + "workflowSessions": "工作流会话", + "scannedFrom": "从 logs/recordings 和 logs/trajectories 扫描", + "averageSuccess": "平均成功率", + "meanSuccessRate": "各会话的平均成功率", + "loadingWorkflows": "正在加载工作流…", + "failedToLoad": "加载工作流失败", + "noSessions": "暂无工作流会话", + "noSessionsDesc": "启用录制运行 `openspace` 后刷新此页面。", + "more": "+{{count}} 更多" + }, + "workflowDetail": { + "loadingDetail": "正在加载工作流详情…", + "failedToLoad": "加载工作流失败", + "workflowNotFound": "未找到工作流", + "backToWorkflows": "← 返回工作流列表", + "workflowDetail": "工作流详情", + "skillsSelected_one": "已选择 {{count}} 个技能", + "skillsSelected_other": "已选择 {{count}} 个技能", + "mergedEvent_one": "{{count}} 个合并事件", + "mergedEvent_other": "{{count}} 个合并事件", + "runDescription": "{{status}}运行,包含 {{iterations}} 和 {{actions}}。", + "started": "开始时间", + "duration": "持续时间", + "stepsLabel": "步骤", + "latestEvent": "最新事件", + "successRate": "成功率", + "successRateHint_one": "{{iterations}}中 {{count}} 次成功迭代", + "successRateHint_other": "{{iterations}}中 {{count}} 次成功迭代", + "iterations": "迭代次数", + "totalRuntime": "总运行时间 {{duration}}", + "activeBackends": "活跃后端", + "mostActive": "最活跃 {{backend}} · {{count}} 个事件", + "noRecordedActivity": "无已记录的工具活动", + "timelineEvents": "时间线事件", + "agentToolHint": "{{agentCount}} 个智能体操作 · {{toolCount}} 个工具事件", + "noTimelineData": "暂无时间线数据", + "noTimelineDesc": "此会话尚未包含轨迹或智能体操作记录。", + "rawEventJson": "原始事件 JSON", + "selection": "技能选择", + "selectedSkills": "已选技能", + "noSelectedSkills": "未选择技能", + "noSelectedSkillsDesc": "此次运行未选择或记录任何技能。", + "session": "会话", + "overview": "概览", + "taskId": "任务 ID", + "runtime": "运行时", + "window": "时间窗口", + "ended": "结束于 {{date}}", + "selectionMethod": "选择方法", + "notRecorded": "未记录", + "enabledBackends": "已启用后端", + "backendActivity": "后端活动", + "iteration_one": "{{count}} 次迭代", + "iteration_other": "{{count}} 次迭代", + "step_one": "{{count}} 步", + "step_other": "{{count}} 步", + "agentAction_one": "{{count}} 个智能体操作", + "agentAction_other": "{{count}} 个智能体操作", + "successfulIteration_one": "{{count}} 次成功迭代", + "successfulIteration_other": "{{count}} 次成功迭代" + }, + "drawer": { + "skillVersion": "技能版本", + "openAsMain": "作为主视图打开", + "versionSummary": "版本摘要", + "noDescriptionAvailable": "此版本暂无描述信息。", + "gen": "第 {{generation}} 代", + "versionScore": "版本评分", + "metrics": "指标", + "executionQuality": "执行质量", + "effectiveRate": "有效率", + "completionRate": "完成率", + "appliedRate": "应用率", + "fallbackRate": "回退率", + "selectionsLabel": "选择次数", + "appliedLabel": "应用次数", + "completionsLabel": "完成次数", + "fallbacksLabel": "回退次数", + "versionMetadata": "版本元数据", + "origin": "来源:", + "generation": "世代:", + "visibility": "可见性:", + "created": "创建时间:", + "firstSeen": "首次出现:", + "lastUpdated": "最后更新:", + "skillPath": "技能路径:", + "skillDir": "技能目录:", + "parentIds": "父级 ID:", + "changeSummary": "变更摘要:", + "effectiveScore": "有效评分:", + "diff": "差异", + "contentDiff": "内容差异", + "diffTooLarge": "差异过大", + "diffTooLargeDesc": "此版本的内容差异过大,内联查看器已禁用。", + "diffUnavailable": "差异不可用", + "diffUnavailableDesc": "此版本有差异数据,但无法解析为统一差异格式。", + "noContentDiff": "无内容差异", + "noContentDiffDesc": "此版本没有存储的内容差异。", + "source": "源代码", + "skillMdPreview": "SKILL.md 预览", + "sourceUnavailable": "源代码不可用", + "sourceUnavailableDesc": "此版本指向的 SKILL.md 路径缺失或不可读。", + "analyses": "分析", + "recentAnalyses": "近期执行分析", + "noExecutionNote": "无执行备注", + "analysisCompleted": "已完成:{{value}} · 工具问题:{{toolIssues}} · 建议:{{suggestions}}", + "noAnalysesYet": "暂无分析", + "noAnalysesDesc": "执行分析将在已记录的任务运行持久化到 SQLite 后显示。" + }, + "filter": { + "origin": "来源:", + "allOrigins": "所有来源", + "tags": "标签:", + "allTags": "所有标签" + }, + "graph": { + "noGraphData": "暂无谱系图数据。", + "tooltipScore": "评分:{{value}}", + "tooltipGeneration": "世代:{{value}}", + "tooltipOrigin": "来源:{{value}}" + }, + "diffViewer": { + "noFiles": "差异中无文件", + "old": "旧版", + "new": "新版" + }, + "format": { + "noInstruction": "未捕获到指令" + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5a34772..153d185 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -32,7 +32,7 @@ --radius-chip: 999px; --radius-card: 32px; --radius-card-sm: 18px; - --font-serif: 'Neuton', Georgia, serif; + --font-serif: 'Neuton', 'Noto Serif CJK SC', 'Songti SC', 'SimSun', Georgia, serif; --font-sans: 'Cabin', system-ui, -apple-system, sans-serif; --font-mono: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace; --shadow-hard: 4px 4px 0px 0px rgba(74, 59, 42, 0.1); diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index c72e8f9..523b231 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -1,4 +1,5 @@ import { NavLink, Outlet } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; const linkClass = ({ isActive }: { isActive: boolean }) => isActive @@ -6,6 +7,12 @@ const linkClass = ({ isActive }: { isActive: boolean }) => : 'hover:text-primary'; export default function MainLayout() { + const { t, i18n } = useTranslation(); + + const toggleLang = () => { + i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh'); + }; + return (
-
API: `localhost:7788` · Vite: `localhost:3888`
+
+ +
API: `localhost:7788` · Vite: `localhost:3888`
+
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e0becf6..fb77ecd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import './i18n'; import './index.css'; import App from './App'; import { ErrorBoundary } from './components/ErrorBoundary'; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 165e47c..6e98cd4 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,11 +1,13 @@ import { Link } from 'react-router-dom'; import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { overviewApi, type OverviewResponse } from '../api'; import MetricCard from '../components/MetricCard'; import EmptyState from '../components/EmptyState'; import { formatDate, formatInstruction, formatPercent, truncate } from '../utils/format'; export default function DashboardPage() { + const { t } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -22,7 +24,7 @@ export default function DashboardPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load overview'); + setError(err instanceof Error ? err.message : t('dashboard.failedToLoad')); } } finally { if (!cancelled) { @@ -34,37 +36,37 @@ export default function DashboardPage() { return () => { cancelled = true; }; - }, []); + }, [t]); if (loading) { - return
Loading dashboard…
; + return
{t('dashboard.loadingDashboard')}
; } if (error || !data) { - return
{error ?? 'Dashboard unavailable'}
; + return
{error ?? t('dashboard.dashboardUnavailable')}
; } return (
-

Dashboard

+

{t('dashboard.title')}

- - - - + + + +
-
Health
-

Runtime snapshot

+
{t('dashboard.health')}
+

{t('dashboard.runtimeSnapshot')}

-
Status{data.health.status}
-
DB Path{data.health.db_path}
-
Workflow Count{data.health.workflow_count}
-
Built Frontend{data.health.frontend_dist_exists ? 'yes' : 'no'}
+
{t('dashboard.status')}{data.health.status}
+
{t('dashboard.dbPath')}{data.health.db_path}
+
{t('dashboard.workflowCount')}{data.health.workflow_count}
+
{t('dashboard.builtFrontend')}{data.health.frontend_dist_exists ? t('common.yes') : t('common.no')}
@@ -72,11 +74,11 @@ export default function DashboardPage() {
-
Skills
-

Top scored skills

+
{t('dashboard.skillsSection')}
+

{t('dashboard.topScoredSkills')}

{data.skills.top.length === 0 ? ( - + ) : (
{data.skills.top.map((skill) => ( @@ -84,17 +86,17 @@ export default function DashboardPage() {
{skill.name}
-
{truncate(skill.description || 'No description', 110)}
+
{truncate(skill.description || t('common.noDescription'), 110)}
{skill.score.toFixed(1)}
-
score
+
{t('common.score')}
- effective {formatPercent(skill.effective_rate)} - applied {formatPercent(skill.applied_rate)} - selections {skill.total_selections} + {t('dashboard.effective', { value: formatPercent(skill.effective_rate) })} + {t('dashboard.applied', { value: formatPercent(skill.applied_rate) })} + {t('dashboard.selections', { count: skill.total_selections })}
))} @@ -104,11 +106,11 @@ export default function DashboardPage() {
-
Workflows
-

Recent sessions

+
{t('dashboard.workflowsSection')}
+

{t('dashboard.recentSessions')}

{data.workflows.recent.length === 0 ? ( - + ) : (
{data.workflows.recent.map((workflow) => ( @@ -116,16 +118,16 @@ export default function DashboardPage() {
{workflow.task_name}
-
{formatInstruction(workflow.instruction, 160)}
+
{formatInstruction(workflow.instruction, 160, t('format.noInstruction'))}
{(workflow.success_rate * 100).toFixed(1)}%
-
success
+
{t('common.success')}
- {workflow.total_steps} steps - {workflow.agent_action_count} agent actions + {t('common.steps', { count: workflow.total_steps })} + {t('common.agentActions', { count: workflow.agent_action_count })} {formatDate(workflow.start_time)}
diff --git a/frontend/src/pages/SkillDetailPage.tsx b/frontend/src/pages/SkillDetailPage.tsx index 8b20ea5..c2de04e 100644 --- a/frontend/src/pages/SkillDetailPage.tsx +++ b/frontend/src/pages/SkillDetailPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { skillsApi, type SkillDetail, type SkillLineage } from '../api'; import EmptyState from '../components/EmptyState'; import MetricCard from '../components/MetricCard'; @@ -26,6 +27,7 @@ function resolveLineageGraph(skill: SkillDetail | null): SkillLineage | null { const DRAWER_ANIMATION_DURATION_MS = 300; export default function SkillDetailPage() { + const { t } = useTranslation(); const { skillId = '' } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const [skillClass, setSkillClass] = useState(null); @@ -52,7 +54,7 @@ export default function SkillDetailPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load skill class'); + setError(err instanceof Error ? err.message : t('skillDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -68,7 +70,7 @@ export default function SkillDetailPage() { return () => { cancelled = true; }; - }, [skillId]); + }, [skillId, t]); const lineageGraph = useMemo(() => resolveLineageGraph(skillClass), [skillClass]); @@ -97,7 +99,7 @@ export default function SkillDetailPage() { } catch (err) { if (!cancelled) { setSelectedVersion(null); - setDrawerError(err instanceof Error ? err.message : 'Failed to load selected version'); + setDrawerError(err instanceof Error ? err.message : t('skillDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -110,7 +112,7 @@ export default function SkillDetailPage() { return () => { cancelled = true; }; - }, [selectedVersionId, skillClass]); + }, [selectedVersionId, skillClass, t]); useEffect(() => { if (selectedVersion) { @@ -215,20 +217,20 @@ export default function SkillDetailPage() { }; if (loading) { - return
Loading skill detail…
; + return
{t('skillDetail.loadingDetail')}
; } if (error || !skillClass) { - return
{error ?? 'Skill not found'}
; + return
{error ?? t('skillDetail.skillNotFound')}
; } return (
- ← Back to Skills + {t('skillDetail.backToSkills')}

{skillClass.name}

-
Skill class anchored on {skillClass.skill_id}
+
{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}
@@ -236,13 +238,13 @@ export default function SkillDetailPage() {
-
Skill Class
-

Evolution overview

+
{t('skillDetail.skillClass')}
+

{t('skillDetail.evolutionOverview')}

{skillClass.category} {skillClass.visibility} - {skillClass.is_active ? 'active tip' : 'inactive anchor'} + {skillClass.is_active ? t('skillDetail.activeTip') : t('skillDetail.inactiveAnchor')} {classSummary.origins.map((origin) => ( {origin} ))} @@ -250,48 +252,48 @@ export default function SkillDetailPage() { {tag} ))} {classSummary.tags.length > 8 ? ( - +{classSummary.tags.length - 8} tags + {t('common.tags', { count: classSummary.tags.length - 8 })} ) : null}
{classSummary.bestScore.toFixed(1)}
-
best version score
+
{t('skillDetail.bestVersionScore')}
-
Skill directory
-
{skillClass.skill_dir || 'Unavailable'}
+
{t('skillDetail.skillDirectory')}
+
{skillClass.skill_dir || t('common.unavailable')}
-
Latest version created
+
{t('skillDetail.latestVersionCreated')}
{formatDate(classSummary.latestCreatedAt)}
-
Representative version
+
{t('skillDetail.representativeVersion')}
{skillClass.skill_id}
-
Representative update
+
{t('skillDetail.representativeUpdate')}
{formatDate(skillClass.last_updated)}
- - - - + + + +
-
Evolution Graph
-

Version lineage

+
{t('skillDetail.evolutionGraph')}
+

{t('skillDetail.versionLineage')}

{drawerLoading ? ( -
Loading version drawer…
+
{t('skillDetail.loadingDrawer')}
) : null} {drawerError ? (
{drawerError}
@@ -317,7 +319,7 @@ export default function SkillDetailPage() {
{lineageGraph && lineageGraph.nodes.length === 0 ? ( - + ) : null} diff --git a/frontend/src/pages/SkillsPage.tsx b/frontend/src/pages/SkillsPage.tsx index 72cd637..5686773 100644 --- a/frontend/src/pages/SkillsPage.tsx +++ b/frontend/src/pages/SkillsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { skillsApi, type Skill, type SkillStats } from '../api'; import EmptyState from '../components/EmptyState'; import MetricCard from '../components/MetricCard'; @@ -7,6 +8,7 @@ import { formatDate, truncate } from '../utils/format'; import { buildSkillClasses } from '../utils/skillClasses'; export default function SkillsPage() { + const { t } = useTranslation(); const [skills, setSkills] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -30,7 +32,7 @@ export default function SkillsPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load skills'); + setError(err instanceof Error ? err.message : t('skills.failedToLoad')); } } finally { if (!cancelled) { @@ -42,7 +44,7 @@ export default function SkillsPage() { return () => { cancelled = true; }; - }, [sort]); + }, [sort, t]); const skillClasses = useMemo(() => buildSkillClasses(skills), [skills]); @@ -89,37 +91,37 @@ export default function SkillsPage() {
-

Skill classes

+

{t('skills.title')}

setQuery(event.target.value)} - placeholder="Search by name, id, description, tag, or origin" + placeholder={t('skills.searchPlaceholder')} className="px-3 py-2 min-w-[320px]" />
{stats ? (
- - - - + + + +
) : null} - {loading ?
Loading skills…
: null} + {loading ?
{t('skills.loadingSkills')}
: null} {error ?
{error}
: null} {!loading && !error && filteredClasses.length === 0 ? ( - + ) : null} {!loading && !error && filteredClasses.length > 0 ? ( @@ -137,18 +139,18 @@ export default function SkillsPage() {
{skillClass.best_score.toFixed(1)}
-
best score
+
{t('skills.bestScore')}
- {truncate(skillClass.representative.description || 'No class description', 160)} + {truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
-
{skillClass.version_count} versions
-
{skillClass.active_count} active
-
{skillClass.total_selections} selections
+
{t('skills.versions', { count: skillClass.version_count })}
+
{t('skills.active', { count: skillClass.active_count })}
+
{t('skills.selectionsCount', { count: skillClass.total_selections })}
{formatDate(skillClass.latest_updated)}
@@ -160,7 +162,7 @@ export default function SkillsPage() { {tag} ))} {skillClass.tags.length > 5 ? ( - +{skillClass.tags.length - 5} tags + {t('common.tags', { count: skillClass.tags.length - 5 })} ) : null}
diff --git a/frontend/src/pages/WorkflowDetailPage.tsx b/frontend/src/pages/WorkflowDetailPage.tsx index fdb36a7..ae8270e 100644 --- a/frontend/src/pages/WorkflowDetailPage.tsx +++ b/frontend/src/pages/WorkflowDetailPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { workflowsApi, type WorkflowDetail, type WorkflowTimelineEvent } from '../api'; import { formatDate, formatInstruction } from '../utils/format'; @@ -48,10 +49,6 @@ function formatPercent(value: number): string { return `${(value * 100).toFixed(1)}%`; } -function pluralize(value: number, singular: string, plural = `${singular}s`): string { - return `${value} ${value === 1 ? singular : plural}`; -} - function getString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value : null; } @@ -367,6 +364,7 @@ function describeTimelineEvent(event: WorkflowTimelineEvent): TimelinePresentati } export default function WorkflowDetailPage() { + const { t } = useTranslation(); const { workflowId = '' } = useParams(); const [workflow, setWorkflow] = useState(null); const [loading, setLoading] = useState(true); @@ -385,7 +383,7 @@ export default function WorkflowDetailPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load workflow'); + setError(err instanceof Error ? err.message : t('workflowDetail.failedToLoad')); } } finally { if (!cancelled) { @@ -399,7 +397,7 @@ export default function WorkflowDetailPage() { return () => { cancelled = true; }; - }, [workflowId]); + }, [workflowId, t]); const timeline = useMemo(() => { const events = workflow?.timeline ?? []; @@ -464,7 +462,7 @@ export default function WorkflowDetailPage() { return (
-
Loading workflow detail…
+
{t('workflowDetail.loadingDetail')}
); @@ -474,7 +472,7 @@ export default function WorkflowDetailPage() { return (
-
{error ?? 'Workflow not found'}
+
{error ?? t('workflowDetail.workflowNotFound')}
); @@ -487,16 +485,16 @@ export default function WorkflowDetailPage() { const skillSelection = isRecord(metadata.skill_selection) ? metadata.skill_selection : null; const selectionMethod = skillSelection && typeof skillSelection.method === 'string' ? skillSelection.method : null; const executionDurationLabel = formatDurationSeconds(workflow.execution_time); - const selectedSkillLabel = `${pluralize(workflow.selected_skills.length, 'skill')} selected`; - const iterationsLabel = pluralize(workflow.iterations, 'iteration'); - const totalStepLabel = pluralize(workflow.total_steps, 'step'); - const actionCountLabel = pluralize(workflow.agent_action_count, 'agent action'); + const selectedSkillLabel = t('workflowDetail.skillsSelected', { count: workflow.selected_skills.length }); + const iterationsLabel = t('workflowDetail.iteration', { count: workflow.iterations }); + const totalStepLabel = t('workflowDetail.step', { count: workflow.total_steps }); + const actionCountLabel = t('workflowDetail.agentAction', { count: workflow.agent_action_count }); const agentActionCount = timelineSummary.byType.agent_action ?? 0; const toolExecutionCount = timelineSummary.byType.tool_execution ?? 0; const latestEventLabel = timelineSummary.lastTimestamp ? formatTimeLabel(timelineSummary.lastTimestamp) : '—'; - const timelineEventLabel = pluralize(timelineSummary.total, 'merged event'); + const timelineEventLabel = t('workflowDetail.mergedEvent', { count: timelineSummary.total }); const statusLabel = humanizeToken(workflow.status || 'unknown'); - const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : 'Not recorded'; + const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : t('workflowDetail.notRecorded'); const successRateLabel = formatPercent(workflow.success_rate); return ( @@ -510,7 +508,7 @@ export default function WorkflowDetailPage() { to="/workflows" className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink" > - ← Back to Workflows + {t('workflowDetail.backToWorkflows')} {statusLabel} {selectedSkillLabel} @@ -518,36 +516,36 @@ export default function WorkflowDetailPage() {
-
Workflow detail
+
{t('workflowDetail.workflowDetail')}

{workflow.task_name}

- {formatInstruction(workflow.instruction, 480)} + {formatInstruction(workflow.instruction, 480, t('format.noInstruction'))}

- {`${statusLabel} run with ${iterationsLabel} and ${actionCountLabel}.`} + {t('workflowDetail.runDescription', { status: statusLabel, iterations: iterationsLabel, actions: actionCountLabel })}

-
Started
+
{t('workflowDetail.started')}
{formatDate(workflow.start_time)}
-
Duration
+
{t('workflowDetail.duration')}
{executionDurationLabel}
-
Steps
+
{t('workflowDetail.stepsLabel')}
{totalStepLabel}
-
Latest event
+
{t('workflowDetail.latestEvent')}
{latestEventLabel}
@@ -556,24 +554,24 @@ export default function WorkflowDetailPage() {
@@ -582,8 +580,8 @@ export default function WorkflowDetailPage() {
{timeline.length === 0 ? ( ) : (
@@ -677,7 +675,7 @@ export default function WorkflowDetailPage() { ) : null}
-
Raw event JSON
+
{t('workflowDetail.rawEventJson')}
                                   {stringify(event.details)}
                                 
@@ -697,8 +695,8 @@ export default function WorkflowDetailPage() {
-
Selection
-

Selected skills

+
{t('workflowDetail.selection')}
+

{t('workflowDetail.selectedSkills')}

{workflow.selected_skills.length > 0 ? {workflow.selected_skills.length} : null}
@@ -718,42 +716,42 @@ export default function WorkflowDetailPage() {
) : (
-
No selected skills
-

No skills were selected or recorded for this run.

+
{t('workflowDetail.noSelectedSkills')}
+

{t('workflowDetail.noSelectedSkillsDesc')}

)}
-
Session
-

Overview

+
{t('workflowDetail.session')}
+

{t('workflowDetail.overview')}

- +
{workflow.task_id}
- +
{executionDurationLabel}
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
- +
{formatDate(workflow.start_time)}
-
Ended {formatDate(workflow.end_time)}
+
{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}
- +
{selectionMethodLabel}
{selectedSkillLabel}
{enabledBackends.length > 0 ? ( - +
{enabledBackends.map((backend) => ( {humanizeToken(backend)} @@ -763,7 +761,7 @@ export default function WorkflowDetailPage() { ) : null} {activityEntries.length > 0 ? ( - +
{activityEntries.map(([backend, count]) => ( {`${humanizeToken(backend)} ${count}`} diff --git a/frontend/src/pages/WorkflowsPage.tsx b/frontend/src/pages/WorkflowsPage.tsx index 7c04aa1..e5a16ca 100644 --- a/frontend/src/pages/WorkflowsPage.tsx +++ b/frontend/src/pages/WorkflowsPage.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { workflowsApi, type WorkflowSummary } from '../api'; import EmptyState from '../components/EmptyState'; import { formatDate, formatInstruction } from '../utils/format'; export default function WorkflowsPage() { + const { t } = useTranslation(); const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -22,7 +24,7 @@ export default function WorkflowsPage() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load workflows'); + setError(err instanceof Error ? err.message : t('workflows.failedToLoad')); } } finally { if (!cancelled) { @@ -34,7 +36,7 @@ export default function WorkflowsPage() { return () => { cancelled = true; }; - }, []); + }, [t]); const filtered = useMemo(() => { const normalized = query.trim().toLowerCase(); @@ -56,34 +58,34 @@ export default function WorkflowsPage() {
-

Recorded sessions

+

{t('workflows.title')}

setQuery(event.target.value)} - placeholder="Search by task name or instruction" + placeholder={t('workflows.searchPlaceholder')} className="px-3 py-2 min-w-[320px]" />
-
Workflow Sessions
+
{t('workflows.workflowSessions')}
{workflows.length}
-
Scanned from logs/recordings and logs/trajectories
+
{t('workflows.scannedFrom')}
-
Average Success
+
{t('workflows.averageSuccess')}
{averageSuccess}%
-
Mean success rate across sessions
+
{t('workflows.meanSuccessRate')}
- {loading ?
Loading workflows…
: null} + {loading ?
{t('workflows.loadingWorkflows')}
: null} {error ?
{error}
: null} {!loading && !error && filtered.length === 0 ? ( - + ) : null} {!loading && !error && filtered.length > 0 ? ( @@ -96,13 +98,13 @@ export default function WorkflowsPage() {
{(workflow.success_rate * 100).toFixed(1)}%
-
success
+
{t('common.success')}
-
{formatInstruction(workflow.instruction, 220)}
+
{formatInstruction(workflow.instruction, 220, t('format.noInstruction'))}
-
{workflow.total_steps} steps
-
{workflow.agent_action_count} agent actions
+
{t('common.steps', { count: workflow.total_steps })}
+
{t('common.agentActions', { count: workflow.agent_action_count })}
{formatDate(workflow.start_time)}
{workflow.selected_skills.length > 0 ? ( @@ -114,7 +116,7 @@ export default function WorkflowsPage() { ))} {workflow.selected_skills.length > 3 ? ( - +{workflow.selected_skills.length - 3} more + {t('workflows.more', { count: workflow.selected_skills.length - 3 })} ) : null}
diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index ba71a98..5d68acf 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -56,8 +56,9 @@ function shortenPaths(text: string, keep = 3): string { export function formatInstruction( raw: string | null | undefined, maxLen?: number, + fallback = 'No instruction captured', ): string { - if (!raw) return 'No instruction captured'; + if (!raw) return fallback; let text = shortenPaths(raw);