mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
feat(frontend): add Chinese (zh) i18n support (#49)
This commit is contained in:
parent
565f09f1c7
commit
e80c70e9c9
19 changed files with 879 additions and 202 deletions
98
frontend/package-lock.json
generated
98
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<Props, State> {
|
|||
}
|
||||
|
||||
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<Props, State> {
|
|||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const t = i18n.t.bind(i18n);
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[color:var(--color-bg-page)]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-[color:var(--color-danger)] mb-4">
|
||||
Something went wrong
|
||||
{t('errorBoundary.title')}
|
||||
</h1>
|
||||
<p className="text-[color:var(--color-muted)] mb-6">
|
||||
An unexpected error occurred
|
||||
{t('errorBoundary.message')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/dashboard'}
|
||||
className="btn-primary"
|
||||
>
|
||||
Go to Dashboard
|
||||
{t('errorBoundary.goToDashboard')}
|
||||
</button>
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mt-4 text-left text-xs text-[color:var(--color-muted)]">
|
||||
<summary>Error details</summary>
|
||||
<summary>{t('errorBoundary.details')}</summary>
|
||||
<pre className="mt-2 p-4 bg-[color:var(--color-surface)] overflow-auto">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
|
|
|
|||
|
|
@ -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 <p className="text-[color:var(--color-muted)] text-sm">No files in diff</p>;
|
||||
return <p className="text-[color:var(--color-muted)] text-sm">{t('diffViewer.noFiles')}</p>;
|
||||
}
|
||||
|
||||
const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0;
|
||||
|
|
@ -216,8 +218,8 @@ export default function DiffViewer({ files }: DiffViewerProps) {
|
|||
return (
|
||||
<div key={`${activeFile.path}-hunk-${hunkIdx}`}>
|
||||
<div className="sticky top-0 z-10 grid grid-cols-[1fr_1fr] border-y border-[color:var(--color-ink)] bg-[#CBCADB] px-3 py-1.5 text-[color:var(--color-muted)] select-none">
|
||||
<div>Old</div>
|
||||
<div>New</div>
|
||||
<div>{t('diffViewer.old')}</div>
|
||||
<div>{t('diffViewer.new')}</div>
|
||||
</div>
|
||||
<div className="sticky top-[29px] z-10 border-b border-[color:var(--color-border-dark)] bg-[color:var(--color-surface)] px-3 py-1 text-[color:var(--color-muted)] select-none">
|
||||
{hunk.header}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const fgRef = useRef<any>(null);
|
||||
const [graphDim, setGraphDim] = useState({ width: 0, height: 0 });
|
||||
|
|
@ -180,7 +182,7 @@ export default function SkillEvolutionGraph({
|
|||
}, []);
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
return <div className="text-sm text-muted p-4">No lineage graph data.</div>;
|
||||
return <div className="text-sm text-muted p-4">{t('graph.noGraphData')}</div>;
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>(null);
|
||||
|
||||
const rawDiff = skill?.lineage.content_diff ?? '';
|
||||
|
|
@ -135,16 +137,16 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
<div className="drawer-scroll flex h-full w-full flex-col overflow-hidden overscroll-contain">
|
||||
<header className="p-4 border-b-2 border-[color:var(--color-border)] flex items-start justify-between gap-3 shrink-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-muted">Skill Version</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted">{t('drawer.skillVersion')}</p>
|
||||
<h2 id="skill-version-drawer-title" className="font-bold text-lg truncate">{skill.name}</h2>
|
||||
<p className="text-xs text-muted font-mono break-all">{skill.skill_id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/skills/${encodeURIComponent(skill.skill_id)}`} className="btn-outline-ink text-sm">
|
||||
Open as main
|
||||
{t('drawer.openAsMain')}
|
||||
</Link>
|
||||
<button type="button" onClick={onClose} ref={closeButtonRef} className="btn-outline-ink text-sm">
|
||||
Close
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -153,13 +155,13 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Version Summary</div>
|
||||
<div className="text-sm text-muted">{skill.description || 'No description available for this version.'}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.versionSummary')}</div>
|
||||
<div className="text-sm text-muted">{skill.description || t('drawer.noDescriptionAvailable')}</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="tag px-2 py-1">{skill.category}</span>
|
||||
<span className="tag px-2 py-1">{skill.origin}</span>
|
||||
<span className="tag px-2 py-1">gen {skill.generation}</span>
|
||||
<span className="tag px-2 py-1">{skill.is_active ? 'active' : 'inactive'}</span>
|
||||
<span className="tag px-2 py-1">{t('drawer.gen', { generation: skill.generation })}</span>
|
||||
<span className="tag px-2 py-1">{skill.is_active ? t('common.active') : t('common.inactive')}</span>
|
||||
{skill.tags.map((tag) => (
|
||||
<span key={tag} className="tag px-2 py-1">{tag}</span>
|
||||
))}
|
||||
|
|
@ -167,67 +169,67 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-4xl font-bold font-serif leading-none">{skill.score.toFixed(1)}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">version score</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('drawer.versionScore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Metrics</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">Execution quality</h3>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.metrics')}</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.executionQuality')}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<ProgressBar label="Effective rate" value={skill.effective_rate} colorClass="bg-primary" />
|
||||
<ProgressBar label="Completion rate" value={skill.completion_rate} colorClass="bg-accent" />
|
||||
<ProgressBar label="Applied rate" value={skill.applied_rate} colorClass="bg-teal" />
|
||||
<ProgressBar label="Fallback rate" value={skill.fallback_rate} colorClass="bg-danger" />
|
||||
<ProgressBar label={t('drawer.effectiveRate')} value={skill.effective_rate} colorClass="bg-primary" />
|
||||
<ProgressBar label={t('drawer.completionRate')} value={skill.completion_rate} colorClass="bg-accent" />
|
||||
<ProgressBar label={t('drawer.appliedRate')} value={skill.applied_rate} colorClass="bg-teal" />
|
||||
<ProgressBar label={t('drawer.fallbackRate')} value={skill.fallback_rate} colorClass="bg-danger" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-muted">
|
||||
<div><div className="font-bold text-ink">Selections</div><div>{skill.total_selections}</div></div>
|
||||
<div><div className="font-bold text-ink">Applied</div><div>{skill.total_applied}</div></div>
|
||||
<div><div className="font-bold text-ink">Completions</div><div>{skill.total_completions}</div></div>
|
||||
<div><div className="font-bold text-ink">Fallbacks</div><div>{skill.total_fallbacks}</div></div>
|
||||
<div><div className="font-bold text-ink">{t('drawer.selectionsLabel')}</div><div>{skill.total_selections}</div></div>
|
||||
<div><div className="font-bold text-ink">{t('drawer.appliedLabel')}</div><div>{skill.total_applied}</div></div>
|
||||
<div><div className="font-bold text-ink">{t('drawer.completionsLabel')}</div><div>{skill.total_completions}</div></div>
|
||||
<div><div className="font-bold text-ink">{t('drawer.fallbacksLabel')}</div><div>{skill.total_fallbacks}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 text-sm space-y-2">
|
||||
<h3 className="font-bold">Version Metadata</h3>
|
||||
<p><strong>Origin:</strong> {skill.origin}</p>
|
||||
<p><strong>Generation:</strong> {skill.generation}</p>
|
||||
<p><strong>Visibility:</strong> {skill.visibility}</p>
|
||||
<p><strong>Created:</strong> {formatDate(skill.lineage.created_at)}</p>
|
||||
<p><strong>First seen:</strong> {formatDate(skill.first_seen)}</p>
|
||||
<p><strong>Last updated:</strong> {formatDate(skill.last_updated)}</p>
|
||||
<p><strong>Skill path:</strong> <span className="break-all">{skill.path || 'Unavailable'}</span></p>
|
||||
<p><strong>Skill dir:</strong> <span className="break-all">{skill.skill_dir || 'Unavailable'}</span></p>
|
||||
<p><strong>Parent IDs:</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : 'None'}</p>
|
||||
<p><strong>Change summary:</strong> {skill.lineage.change_summary || 'None'}</p>
|
||||
<p><strong>Effective score:</strong> {formatPercent(skill.effective_rate)}</p>
|
||||
<h3 className="font-bold">{t('drawer.versionMetadata')}</h3>
|
||||
<p><strong>{t('drawer.origin')}</strong> {skill.origin}</p>
|
||||
<p><strong>{t('drawer.generation')}</strong> {skill.generation}</p>
|
||||
<p><strong>{t('drawer.visibility')}</strong> {skill.visibility}</p>
|
||||
<p><strong>{t('drawer.created')}</strong> {formatDate(skill.lineage.created_at)}</p>
|
||||
<p><strong>{t('drawer.firstSeen')}</strong> {formatDate(skill.first_seen)}</p>
|
||||
<p><strong>{t('drawer.lastUpdated')}</strong> {formatDate(skill.last_updated)}</p>
|
||||
<p><strong>{t('drawer.skillPath')}</strong> <span className="break-all">{skill.path || t('common.unavailable')}</span></p>
|
||||
<p><strong>{t('drawer.skillDir')}</strong> <span className="break-all">{skill.skill_dir || t('common.unavailable')}</span></p>
|
||||
<p><strong>{t('drawer.parentIds')}</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : t('common.none')}</p>
|
||||
<p><strong>{t('drawer.changeSummary')}</strong> {skill.lineage.change_summary || t('common.none')}</p>
|
||||
<p><strong>{t('drawer.effectiveScore')}</strong> {formatPercent(skill.effective_rate)}</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Diff</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">Content diff</h3>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.diff')}</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.contentDiff')}</h3>
|
||||
</div>
|
||||
{isOversizedDiff ? (
|
||||
<EmptyState title="Diff too large" description="This version has a very large content diff, so the inline viewer is disabled." />
|
||||
<EmptyState title={t('drawer.diffTooLarge')} description={t('drawer.diffTooLargeDesc')} />
|
||||
) : canShowDiff ? (
|
||||
diffFiles.length > 0 ? (
|
||||
<DiffViewer files={diffFiles} />
|
||||
) : (
|
||||
<EmptyState title="Diff unavailable" description="This version has a diff payload, but it could not be parsed as a unified diff." />
|
||||
<EmptyState title={t('drawer.diffUnavailable')} description={t('drawer.diffUnavailableDesc')} />
|
||||
)
|
||||
) : (
|
||||
<EmptyState title="No content diff" description="This version does not have a stored content diff." />
|
||||
<EmptyState title={t('drawer.noContentDiff')} description={t('drawer.noContentDiffDesc')} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Source</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">SKILL.md preview</h3>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.source')}</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.skillMdPreview')}</h3>
|
||||
</div>
|
||||
{sourcePreview ? (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -235,14 +237,14 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
<pre className="field-surface p-4 text-xs overflow-auto max-h-[320px] whitespace-pre-wrap">{sourcePreview.content}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Source unavailable" description="This version points to a missing or unreadable SKILL.md path." />
|
||||
<EmptyState title={t('drawer.sourceUnavailable')} description={t('drawer.sourceUnavailableDesc')} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Analyses</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">Recent execution analyses</h3>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.analyses')}</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.recentAnalyses')}</h3>
|
||||
</div>
|
||||
{skill.recent_analyses.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -252,15 +254,19 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
<div className="font-bold truncate">{analysis.task_id}</div>
|
||||
<div className="text-xs text-muted">{formatDate(analysis.timestamp)}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted">{truncate(analysis.execution_note || 'No execution note', 220)}</div>
|
||||
<div className="text-sm text-muted">{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}</div>
|
||||
<div className="text-xs text-muted">
|
||||
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,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No analyses yet" description="Execution analyses will appear after recorded task runs are persisted into SQLite." />
|
||||
<EmptyState title={t('drawer.noAnalysesYet')} description={t('drawer.noAnalysesDesc')} />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Origin:</label>
|
||||
<label className="text-sm font-medium">{t('filter.origin')}</label>
|
||||
<select
|
||||
value={originFilter}
|
||||
onChange={(event) => onOriginFilterChange(event.target.value)}
|
||||
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">All Origins</option>
|
||||
<option value="all">{t('filter.allOrigins')}</option>
|
||||
{allOrigins.map((origin) => (
|
||||
<option key={origin} value={origin}>{origin}</option>
|
||||
))}
|
||||
|
|
@ -32,13 +36,13 @@ export default function SkillVersionFilterBar({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Tags:</label>
|
||||
<label className="text-sm font-medium">{t('filter.tags')}</label>
|
||||
<select
|
||||
value={tagFilter}
|
||||
onChange={(event) => onTagFilterChange(event.target.value)}
|
||||
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">All Tags</option>
|
||||
<option value="all">{t('filter.allTags')}</option>
|
||||
{allTags.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
|
|
|
|||
253
frontend/src/i18n/en.json
Normal file
253
frontend/src/i18n/en.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
34
frontend/src/i18n/index.ts
Normal file
34
frontend/src/i18n/index.ts
Normal file
|
|
@ -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;
|
||||
253
frontend/src/i18n/zh.json
Normal file
253
frontend/src/i18n/zh.json
Normal file
|
|
@ -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": "未捕获到指令"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="h-screen min-w-[1180px] relative flex flex-col overflow-x-auto overflow-y-hidden bg-bg-page text-ink">
|
||||
<nav className="relative z-10 flex justify-between items-center px-4 py-3 border-b border-[color:var(--color-border)] bg-bg-page">
|
||||
|
|
@ -13,17 +20,26 @@ export default function MainLayout() {
|
|||
<div className="font-bold text-3xl tracking-tighter font-serif">OpenSpace</div>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<NavLink to="/dashboard" className={linkClass}>
|
||||
Dashboard
|
||||
{t('nav.dashboard')}
|
||||
</NavLink>
|
||||
<NavLink to="/skills" className={linkClass}>
|
||||
Skills
|
||||
{t('nav.skills')}
|
||||
</NavLink>
|
||||
<NavLink to="/workflows" className={linkClass}>
|
||||
Workflows
|
||||
{t('nav.workflows')}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted">API: `localhost:7788` · Vite: `localhost:3888`</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLang}
|
||||
className="px-2.5 py-1 text-xs border border-[color:var(--color-border-dark)] rounded hover:bg-[color:var(--color-surface)] transition-colors cursor-pointer bg-transparent text-ink"
|
||||
>
|
||||
{i18n.language === 'zh' ? 'EN' : '中文'}
|
||||
</button>
|
||||
<div className="text-xs text-muted">API: `localhost:7788` · Vite: `localhost:3888`</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="app-scroll-region relative z-10 min-h-0 flex-1 overflow-auto">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<OverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <div className="p-6 text-sm text-muted">Loading dashboard…</div>;
|
||||
return <div className="p-6 text-sm text-muted">{t('dashboard.loadingDashboard')}</div>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <div className="p-6 text-sm text-danger">{error ?? 'Dashboard unavailable'}</div>;
|
||||
return <div className="p-6 text-sm text-danger">{error ?? t('dashboard.dashboardUnavailable')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-3xl font-bold font-serif">Dashboard</h1>
|
||||
<h1 className="text-3xl font-bold font-serif">{t('dashboard.title')}</h1>
|
||||
<section className="metrics-row">
|
||||
<MetricCard label="Total Skills" value={data.skills.summary.total_skills_all} hint={`Active: ${data.skills.summary.total_skills}`} />
|
||||
<MetricCard label="Average Skill Score" value={data.skills.average_score.toFixed(1)} hint="Primary metric = effective rate × 100" />
|
||||
<MetricCard label="Workflow Sessions" value={data.workflows.total} hint={`Recorded under ${data.health.db_path.includes('.openspace') ? 'local repo' : 'workspace'}`} />
|
||||
<MetricCard label="Workflow Success" value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint="Average session success rate" />
|
||||
<MetricCard label={t('dashboard.totalSkills')} value={data.skills.summary.total_skills_all} hint={t('dashboard.activeHint', { count: data.skills.summary.total_skills })} />
|
||||
<MetricCard label={t('dashboard.avgSkillScore')} value={data.skills.average_score.toFixed(1)} hint={t('dashboard.avgScoreHint')} />
|
||||
<MetricCard label={t('dashboard.workflowSessions')} value={data.workflows.total} hint={t('dashboard.recordedUnder', { location: data.health.db_path.includes('.openspace') ? t('dashboard.localRepo') : t('dashboard.workspace') })} />
|
||||
<MetricCard label={t('dashboard.workflowSuccess')} value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint={t('dashboard.avgSuccessHint')} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Health</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Runtime snapshot</h2>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.health')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.runtimeSnapshot')}</h2>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between"><span className="text-muted">Status</span><span>{data.health.status}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">DB Path</span><span className="text-right break-all">{data.health.db_path}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">Workflow Count</span><span>{data.health.workflow_count}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">Built Frontend</span><span>{data.health.frontend_dist_exists ? 'yes' : 'no'}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.status')}</span><span>{data.health.status}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.dbPath')}</span><span className="text-right break-all">{data.health.db_path}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.workflowCount')}</span><span>{data.health.workflow_count}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.builtFrontend')}</span><span>{data.health.frontend_dist_exists ? t('common.yes') : t('common.no')}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -72,11 +74,11 @@ export default function DashboardPage() {
|
|||
<section className="grid grid-cols-2 gap-6">
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Skills</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Top scored skills</h2>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.skillsSection')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.topScoredSkills')}</h2>
|
||||
</div>
|
||||
{data.skills.top.length === 0 ? (
|
||||
<EmptyState title="No skills yet" description="Run OpenSpace tasks or sync skills into the local registry first." />
|
||||
<EmptyState title={t('dashboard.noSkillsYet')} description={t('dashboard.noSkillsDesc')} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.skills.top.map((skill) => (
|
||||
|
|
@ -84,17 +86,17 @@ export default function DashboardPage() {
|
|||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-bold truncate">{skill.name}</div>
|
||||
<div className="text-sm text-muted">{truncate(skill.description || 'No description', 110)}</div>
|
||||
<div className="text-sm text-muted">{truncate(skill.description || t('common.noDescription'), 110)}</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-2xl font-bold font-serif">{skill.score.toFixed(1)}</div>
|
||||
<div className="text-xs text-muted">score</div>
|
||||
<div className="text-xs text-muted">{t('common.score')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted">
|
||||
<span>effective {formatPercent(skill.effective_rate)}</span>
|
||||
<span>applied {formatPercent(skill.applied_rate)}</span>
|
||||
<span>selections {skill.total_selections}</span>
|
||||
<span>{t('dashboard.effective', { value: formatPercent(skill.effective_rate) })}</span>
|
||||
<span>{t('dashboard.applied', { value: formatPercent(skill.applied_rate) })}</span>
|
||||
<span>{t('dashboard.selections', { count: skill.total_selections })}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -104,11 +106,11 @@ export default function DashboardPage() {
|
|||
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Workflows</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Recent sessions</h2>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.workflowsSection')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.recentSessions')}</h2>
|
||||
</div>
|
||||
{data.workflows.recent.length === 0 ? (
|
||||
<EmptyState title="No workflow sessions" description="Recordings will appear after a task is executed with recording enabled." />
|
||||
<EmptyState title={t('dashboard.noWorkflowSessions')} description={t('dashboard.noWorkflowDesc')} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.workflows.recent.map((workflow) => (
|
||||
|
|
@ -116,16 +118,16 @@ export default function DashboardPage() {
|
|||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-bold truncate">{workflow.task_name}</div>
|
||||
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 160)}</div>
|
||||
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 160, t('format.noInstruction'))}</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
|
||||
<div className="text-xs text-muted">success</div>
|
||||
<div className="text-xs text-muted">{t('common.success')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted">
|
||||
<span>{workflow.total_steps} steps</span>
|
||||
<span>{workflow.agent_action_count} agent actions</span>
|
||||
<span>{t('common.steps', { count: workflow.total_steps })}</span>
|
||||
<span>{t('common.agentActions', { count: workflow.agent_action_count })}</span>
|
||||
<span>{formatDate(workflow.start_time)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -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<SkillDetail | null>(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 <div className="p-6 text-sm text-muted">Loading skill detail…</div>;
|
||||
return <div className="p-6 text-sm text-muted">{t('skillDetail.loadingDetail')}</div>;
|
||||
}
|
||||
|
||||
if (error || !skillClass) {
|
||||
return <div className="p-6 text-sm text-danger">{error ?? 'Skill not found'}</div>;
|
||||
return <div className="p-6 text-sm text-danger">{error ?? t('skillDetail.skillNotFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/skills" className="chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink">← Back to Skills</Link>
|
||||
<Link to="/skills" className="chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink">{t('skillDetail.backToSkills')}</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-3xl font-bold font-serif truncate">{skillClass.name}</h1>
|
||||
<div className="text-sm text-muted mt-1">Skill class anchored on {skillClass.skill_id}</div>
|
||||
<div className="text-sm text-muted mt-1">{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -236,13 +238,13 @@ export default function SkillDetailPage() {
|
|||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="space-y-3 min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Skill Class</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Evolution overview</h2>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.skillClass')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.evolutionOverview')}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="tag px-2 py-1">{skillClass.category}</span>
|
||||
<span className="tag px-2 py-1">{skillClass.visibility}</span>
|
||||
<span className="tag px-2 py-1">{skillClass.is_active ? 'active tip' : 'inactive anchor'}</span>
|
||||
<span className="tag px-2 py-1">{skillClass.is_active ? t('skillDetail.activeTip') : t('skillDetail.inactiveAnchor')}</span>
|
||||
{classSummary.origins.map((origin) => (
|
||||
<span key={origin} className="tag px-2 py-1">{origin}</span>
|
||||
))}
|
||||
|
|
@ -250,48 +252,48 @@ export default function SkillDetailPage() {
|
|||
<span key={tag} className="tag px-2 py-1">{tag}</span>
|
||||
))}
|
||||
{classSummary.tags.length > 8 ? (
|
||||
<span className="tag px-2 py-1">+{classSummary.tags.length - 8} tags</span>
|
||||
<span className="tag px-2 py-1">{t('common.tags', { count: classSummary.tags.length - 8 })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-5xl font-bold font-serif leading-none">{classSummary.bestScore.toFixed(1)}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">best version score</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('skillDetail.bestVersionScore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-muted">
|
||||
<div>
|
||||
<div className="font-bold text-ink">Skill directory</div>
|
||||
<div className="break-all">{skillClass.skill_dir || 'Unavailable'}</div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.skillDirectory')}</div>
|
||||
<div className="break-all">{skillClass.skill_dir || t('common.unavailable')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-ink">Latest version created</div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.latestVersionCreated')}</div>
|
||||
<div>{formatDate(classSummary.latestCreatedAt)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-ink">Representative version</div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.representativeVersion')}</div>
|
||||
<div className="break-all">{skillClass.skill_id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-ink">Representative update</div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.representativeUpdate')}</div>
|
||||
<div>{formatDate(skillClass.last_updated)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-row">
|
||||
<MetricCard label="Versions" value={classSummary.versionCount} hint={`Max generation ${classSummary.maxGeneration}`} />
|
||||
<MetricCard label="Active Versions" value={classSummary.activeCount} hint={`Origins: ${classSummary.origins.length}`} />
|
||||
<MetricCard label="Average Score" value={classSummary.averageScore.toFixed(1)} hint="Across all versions in this lineage" />
|
||||
<MetricCard label="Selections" value={classSummary.totalSelections} hint={`Representative score ${skillClass.score.toFixed(1)}`} />
|
||||
<MetricCard label={t('skillDetail.versions')} value={classSummary.versionCount} hint={t('skillDetail.maxGeneration', { count: classSummary.maxGeneration })} />
|
||||
<MetricCard label={t('skillDetail.activeVersions')} value={classSummary.activeCount} hint={t('skillDetail.originsCount', { count: classSummary.origins.length })} />
|
||||
<MetricCard label={t('skillDetail.averageScore')} value={classSummary.averageScore.toFixed(1)} hint={t('skillDetail.acrossAllVersions')} />
|
||||
<MetricCard label={t('skillDetail.selections')} value={classSummary.totalSelections} hint={t('skillDetail.representativeScore', { score: skillClass.score.toFixed(1) })} />
|
||||
</section>
|
||||
|
||||
<section className="panel-surface overflow-hidden relative min-h-[620px]">
|
||||
<div className="px-5 py-4 border-b border-[color:var(--color-border)] bg-surface flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Evolution Graph</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Version lineage</h2>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.evolutionGraph')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.versionLineage')}</h2>
|
||||
</div>
|
||||
<SkillVersionFilterBar
|
||||
originFilter={originFilter}
|
||||
|
|
@ -309,7 +311,7 @@ export default function SkillDetailPage() {
|
|||
onBackgroundClick={closeDrawer}
|
||||
/>
|
||||
{drawerLoading ? (
|
||||
<div className="absolute bottom-4 left-4 text-xs text-muted">Loading version drawer…</div>
|
||||
<div className="absolute bottom-4 left-4 text-xs text-muted">{t('skillDetail.loadingDrawer')}</div>
|
||||
) : null}
|
||||
{drawerError ? (
|
||||
<div className="absolute bottom-4 left-4 text-xs text-danger">{drawerError}</div>
|
||||
|
|
@ -317,7 +319,7 @@ export default function SkillDetailPage() {
|
|||
</section>
|
||||
|
||||
{lineageGraph && lineageGraph.nodes.length === 0 ? (
|
||||
<EmptyState title="No lineage graph" description="This skill does not yet have lineage data to visualize." />
|
||||
<EmptyState title={t('skillDetail.noLineageGraph')} description={t('skillDetail.noLineageGraphDesc')} />
|
||||
) : null}
|
||||
|
||||
<SkillVersionDrawer skill={drawerVersion} isOpen={Boolean(selectedVersion)} onClose={closeDrawer} />
|
||||
|
|
|
|||
|
|
@ -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<Skill[]>([]);
|
||||
const [stats, setStats] = useState<SkillStats | null>(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() {
|
|||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-serif">Skill classes</h1>
|
||||
<h1 className="text-3xl font-bold font-serif">{t('skills.title')}</h1>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => 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]"
|
||||
/>
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className="px-3 py-2">
|
||||
<option value="score">Sort by best score</option>
|
||||
<option value="updated">Sort by updated time</option>
|
||||
<option value="name">Sort by name</option>
|
||||
<option value="score">{t('skills.sortByScore')}</option>
|
||||
<option value="updated">{t('skills.sortByUpdated')}</option>
|
||||
<option value="name">{t('skills.sortByName')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats ? (
|
||||
<section className="metrics-row">
|
||||
<MetricCard label="Skill Classes" value={skillClasses.length} hint={`Versions: ${stats.total_skills_all}`} />
|
||||
<MetricCard label="Active Versions" value={totalActiveVersions} hint={`With activity: ${stats.skills_with_activity}`} />
|
||||
<MetricCard label="Average Best Score" value={averageBestScore.toFixed(1)} hint="Best node score per class" />
|
||||
<MetricCard label="Selections" value={stats.total_selections} hint={`Completions: ${stats.total_completions}`} />
|
||||
<MetricCard label={t('skills.skillClasses')} value={skillClasses.length} hint={t('skills.versionsHint', { count: stats.total_skills_all })} />
|
||||
<MetricCard label={t('skills.activeVersions')} value={totalActiveVersions} hint={t('skills.withActivity', { count: stats.skills_with_activity })} />
|
||||
<MetricCard label={t('skills.avgBestScore')} value={averageBestScore.toFixed(1)} hint={t('skills.bestNodeScoreHint')} />
|
||||
<MetricCard label={t('skills.selections')} value={stats.total_selections} hint={t('skills.completionsHint', { count: stats.total_completions })} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{loading ? <div className="text-sm text-muted">Loading skills…</div> : null}
|
||||
{loading ? <div className="text-sm text-muted">{t('skills.loadingSkills')}</div> : null}
|
||||
{error ? <div className="text-sm text-danger">{error}</div> : null}
|
||||
|
||||
{!loading && !error && filteredClasses.length === 0 ? (
|
||||
<EmptyState title="No skills match" description="Try another keyword, or execute tasks so new skill telemetry lands in SQLite." />
|
||||
<EmptyState title={t('skills.noSkillsMatch')} description={t('skills.noSkillsMatchDesc')} />
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filteredClasses.length > 0 ? (
|
||||
|
|
@ -137,18 +139,18 @@ export default function SkillsPage() {
|
|||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-3xl font-bold font-serif leading-none">{skillClass.best_score.toFixed(1)}</div>
|
||||
<div className="text-xs text-muted">best score</div>
|
||||
<div className="text-xs text-muted">{t('skills.bestScore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted">
|
||||
{truncate(skillClass.representative.description || 'No class description', 160)}
|
||||
{truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 text-xs text-muted">
|
||||
<div>{skillClass.version_count} versions</div>
|
||||
<div>{skillClass.active_count} active</div>
|
||||
<div>{skillClass.total_selections} selections</div>
|
||||
<div>{t('skills.versions', { count: skillClass.version_count })}</div>
|
||||
<div>{t('skills.active', { count: skillClass.active_count })}</div>
|
||||
<div>{t('skills.selectionsCount', { count: skillClass.total_selections })}</div>
|
||||
<div>{formatDate(skillClass.latest_updated)}</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -160,7 +162,7 @@ export default function SkillsPage() {
|
|||
<span key={`${skillClass.class_id}-${tag}`} className="tag px-2 py-1">{tag}</span>
|
||||
))}
|
||||
{skillClass.tags.length > 5 ? (
|
||||
<span className="tag px-2 py-1">+{skillClass.tags.length - 5} tags</span>
|
||||
<span className="tag px-2 py-1">{t('common.tags', { count: skillClass.tags.length - 5 })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -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<WorkflowDetail | null>(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 (
|
||||
<div className="workflow-detail-page p-6">
|
||||
<div className="mx-auto max-w-[1480px]">
|
||||
<div className="workflow-panel p-6 text-sm text-muted">Loading workflow detail…</div>
|
||||
<div className="workflow-panel p-6 text-sm text-muted">{t('workflowDetail.loadingDetail')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -474,7 +472,7 @@ export default function WorkflowDetailPage() {
|
|||
return (
|
||||
<div className="workflow-detail-page p-6">
|
||||
<div className="mx-auto max-w-[1480px]">
|
||||
<div className="workflow-panel p-6 text-sm text-danger">{error ?? 'Workflow not found'}</div>
|
||||
<div className="workflow-panel p-6 text-sm text-danger">{error ?? t('workflowDetail.workflowNotFound')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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')}
|
||||
</Link>
|
||||
<WorkflowChip className={getStatusChipClasses(workflow.status)}>{statusLabel}</WorkflowChip>
|
||||
<WorkflowChip>{selectedSkillLabel}</WorkflowChip>
|
||||
|
|
@ -518,36 +516,36 @@ export default function WorkflowDetailPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="workflow-kicker">Workflow detail</div>
|
||||
<div className="workflow-kicker">{t('workflowDetail.workflowDetail')}</div>
|
||||
<h1 className="max-w-5xl text-4xl font-semibold leading-[1.05] tracking-[-0.05em] text-ink lg:text-5xl xl:text-[3.6rem]">
|
||||
{workflow.task_name}
|
||||
</h1>
|
||||
<p className="workflow-copy max-w-4xl text-lg leading-8 text-muted line-clamp-4">
|
||||
{formatInstruction(workflow.instruction, 480)}
|
||||
{formatInstruction(workflow.instruction, 480, t('format.noInstruction'))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workflow-soft-card w-full max-w-sm shrink-0 p-5 space-y-5">
|
||||
<p className="workflow-copy text-base leading-7 text-muted">
|
||||
{`${statusLabel} run with ${iterationsLabel} and ${actionCountLabel}.`}
|
||||
{t('workflowDetail.runDescription', { status: statusLabel, iterations: iterationsLabel, actions: actionCountLabel })}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div className="workflow-kicker">Started</div>
|
||||
<div className="workflow-kicker">{t('workflowDetail.started')}</div>
|
||||
<div className="text-base font-medium text-ink">{formatDate(workflow.start_time)}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="workflow-kicker">Duration</div>
|
||||
<div className="workflow-kicker">{t('workflowDetail.duration')}</div>
|
||||
<div className="text-base font-medium text-ink">{executionDurationLabel}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="workflow-kicker">Steps</div>
|
||||
<div className="workflow-kicker">{t('workflowDetail.stepsLabel')}</div>
|
||||
<div className="text-base font-medium text-ink">{totalStepLabel}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="workflow-kicker">Latest event</div>
|
||||
<div className="workflow-kicker">{t('workflowDetail.latestEvent')}</div>
|
||||
<div className="text-base font-medium text-ink">{latestEventLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -556,24 +554,24 @@ export default function WorkflowDetailPage() {
|
|||
|
||||
<section className="workflow-metrics-row">
|
||||
<SummaryMetric
|
||||
label="Success rate"
|
||||
label={t('workflowDetail.successRate')}
|
||||
value={successRateLabel}
|
||||
hint={`${pluralize(workflow.success_count, 'successful iteration')} out of ${iterationsLabel}`}
|
||||
hint={t('workflowDetail.successRateHint', { count: workflow.success_count, iterations: iterationsLabel })}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="Iterations"
|
||||
label={t('workflowDetail.iterations')}
|
||||
value={workflow.iterations}
|
||||
hint={`${executionDurationLabel} total runtime`}
|
||||
hint={t('workflowDetail.totalRuntime', { duration: executionDurationLabel })}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="Active backends"
|
||||
label={t('workflowDetail.activeBackends')}
|
||||
value={activityEntries.length}
|
||||
hint={topBackendEntry ? `Most active ${humanizeToken(topBackendEntry[0])} · ${topBackendEntry[1]} events` : 'No recorded tool activity'}
|
||||
hint={topBackendEntry ? t('workflowDetail.mostActive', { backend: humanizeToken(topBackendEntry[0]), count: topBackendEntry[1] }) : t('workflowDetail.noRecordedActivity')}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="Timeline events"
|
||||
label={t('workflowDetail.timelineEvents')}
|
||||
value={timelineSummary.total}
|
||||
hint={`${agentActionCount} agent actions · ${toolExecutionCount} tool events`}
|
||||
hint={t('workflowDetail.agentToolHint', { agentCount: agentActionCount, toolCount: toolExecutionCount })}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
|
|
@ -582,8 +580,8 @@ export default function WorkflowDetailPage() {
|
|||
<div className="workflow-panel p-5 space-y-4">
|
||||
{timeline.length === 0 ? (
|
||||
<QuietEmptyState
|
||||
title="No timeline data"
|
||||
description="This session does not yet contain trajectory or agent action records."
|
||||
title={t('workflowDetail.noTimelineData')}
|
||||
description={t('workflowDetail.noTimelineDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div role="list" aria-label="Workflow timeline events">
|
||||
|
|
@ -677,7 +675,7 @@ export default function WorkflowDetailPage() {
|
|||
) : null}
|
||||
|
||||
<div className="workflow-soft-card p-3.5">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">Raw event JSON</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">{t('workflowDetail.rawEventJson')}</div>
|
||||
<pre className="workflow-json mt-3 whitespace-pre-wrap break-all text-xs leading-6 text-muted">
|
||||
{stringify(event.details)}
|
||||
</pre>
|
||||
|
|
@ -697,8 +695,8 @@ export default function WorkflowDetailPage() {
|
|||
<section className="workflow-panel p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="workflow-kicker">Selection</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">Selected skills</h2>
|
||||
<div className="workflow-kicker">{t('workflowDetail.selection')}</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.selectedSkills')}</h2>
|
||||
</div>
|
||||
{workflow.selected_skills.length > 0 ? <WorkflowChip>{workflow.selected_skills.length}</WorkflowChip> : null}
|
||||
</div>
|
||||
|
|
@ -718,42 +716,42 @@ export default function WorkflowDetailPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">No selected skills</div>
|
||||
<p className="workflow-copy text-sm leading-6 text-muted">No skills were selected or recorded for this run.</p>
|
||||
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">{t('workflowDetail.noSelectedSkills')}</div>
|
||||
<p className="workflow-copy text-sm leading-6 text-muted">{t('workflowDetail.noSelectedSkillsDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="workflow-panel p-5 space-y-5">
|
||||
<div>
|
||||
<div className="workflow-kicker">Session</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">Overview</h2>
|
||||
<div className="workflow-kicker">{t('workflowDetail.session')}</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.overview')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<SidebarRow label="Task ID">
|
||||
<SidebarRow label={t('workflowDetail.taskId')}>
|
||||
<div className="break-all">{workflow.task_id}</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label="Runtime">
|
||||
<SidebarRow label={t('workflowDetail.runtime')}>
|
||||
<div>{executionDurationLabel}</div>
|
||||
<div className="text-xs leading-6 text-muted">
|
||||
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
|
||||
</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label="Window">
|
||||
<SidebarRow label={t('workflowDetail.window')}>
|
||||
<div>{formatDate(workflow.start_time)}</div>
|
||||
<div className="text-xs leading-6 text-muted">Ended {formatDate(workflow.end_time)}</div>
|
||||
<div className="text-xs leading-6 text-muted">{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label="Selection method">
|
||||
<SidebarRow label={t('workflowDetail.selectionMethod')}>
|
||||
<div>{selectionMethodLabel}</div>
|
||||
<div className="text-xs leading-6 text-muted">{selectedSkillLabel}</div>
|
||||
</SidebarRow>
|
||||
|
||||
{enabledBackends.length > 0 ? (
|
||||
<SidebarRow label="Enabled backends">
|
||||
<SidebarRow label={t('workflowDetail.enabledBackends')}>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{enabledBackends.map((backend) => (
|
||||
<WorkflowChip key={backend}>{humanizeToken(backend)}</WorkflowChip>
|
||||
|
|
@ -763,7 +761,7 @@ export default function WorkflowDetailPage() {
|
|||
) : null}
|
||||
|
||||
{activityEntries.length > 0 ? (
|
||||
<SidebarRow label="Backend activity">
|
||||
<SidebarRow label={t('workflowDetail.backendActivity')}>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{activityEntries.map(([backend, count]) => (
|
||||
<WorkflowChip key={backend}>{`${humanizeToken(backend)} ${count}`}</WorkflowChip>
|
||||
|
|
|
|||
|
|
@ -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<WorkflowSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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() {
|
|||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-serif">Recorded sessions</h1>
|
||||
<h1 className="text-3xl font-bold font-serif">{t('workflows.title')}</h1>
|
||||
</div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search by task name or instruction"
|
||||
placeholder={t('workflows.searchPlaceholder')}
|
||||
className="px-3 py-2 min-w-[320px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Workflow Sessions</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.workflowSessions')}</div>
|
||||
<div className="text-3xl font-bold font-serif leading-none">{workflows.length}</div>
|
||||
<div className="text-xs text-muted">Scanned from logs/recordings and logs/trajectories</div>
|
||||
<div className="text-xs text-muted">{t('workflows.scannedFrom')}</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Average Success</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.averageSuccess')}</div>
|
||||
<div className="text-3xl font-bold font-serif leading-none">{averageSuccess}%</div>
|
||||
<div className="text-xs text-muted">Mean success rate across sessions</div>
|
||||
<div className="text-xs text-muted">{t('workflows.meanSuccessRate')}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? <div className="text-sm text-muted">Loading workflows…</div> : null}
|
||||
{loading ? <div className="text-sm text-muted">{t('workflows.loadingWorkflows')}</div> : null}
|
||||
{error ? <div className="text-sm text-danger">{error}</div> : null}
|
||||
|
||||
{!loading && !error && filtered.length === 0 ? (
|
||||
<EmptyState title="No workflow sessions" description="Run `openspace` with recording enabled, then refresh this page." />
|
||||
<EmptyState title={t('workflows.noSessions')} description={t('workflows.noSessionsDesc')} />
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filtered.length > 0 ? (
|
||||
|
|
@ -96,13 +98,13 @@ export default function WorkflowsPage() {
|
|||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
|
||||
<div className="text-xs text-muted">success</div>
|
||||
<div className="text-xs text-muted">{t('common.success')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 220)}</div>
|
||||
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 220, t('format.noInstruction'))}</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
|
||||
<div>{workflow.total_steps} steps</div>
|
||||
<div>{workflow.agent_action_count} agent actions</div>
|
||||
<div>{t('common.steps', { count: workflow.total_steps })}</div>
|
||||
<div>{t('common.agentActions', { count: workflow.agent_action_count })}</div>
|
||||
<div>{formatDate(workflow.start_time)}</div>
|
||||
</div>
|
||||
{workflow.selected_skills.length > 0 ? (
|
||||
|
|
@ -114,7 +116,7 @@ export default function WorkflowsPage() {
|
|||
))}
|
||||
{workflow.selected_skills.length > 3 ? (
|
||||
<span className="tag px-2 py-1 text-muted">
|
||||
+{workflow.selected_skills.length - 3} more
|
||||
{t('workflows.more', { count: workflow.selected_skills.length - 3 })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue