From c241f42b9895198c85f09b36f7a955741d442056 Mon Sep 17 00:00:00 2001 From: liyuqing Date: Fri, 14 Aug 2026 11:35:57 +0800 Subject: [PATCH 1/3] feat(sight): make dashboard UI bilingual (en/zh) Replace hardcoded UI strings across dashboard pages, security views and shared components with t() lookups backed by a ~470-key en-US/zh-CN catalog. t() gains {placeholder} interpolation and useLocaleTag() drives locale-aware date formatting, so switching language updates text and dates without reload. Constant label maps become MessageKey lookup tables resolved at render time; non-React helpers receive t as a parameter instead of owning hardcoded strings. Server-side token savings labels switch to English because the API cannot know the client locale. Known limitation: OptimizationPage, RiskEnforcementPage, SystemAuditPage, SkillMetricsPage, AgentSessionsPage and related components still contain hardcoded Chinese and will be migrated in a follow-up. Signed-off-by: liyuqing --- .../src/components/CausalAttributionPanel.tsx | 6 +- .../src/components/EvaluationBadge.tsx | 20 +- .../src/components/EvaluationPanel.tsx | 230 ++--- .../src/components/InterruptionBadge.tsx | 40 +- .../src/components/InterruptionPanel.tsx | 54 +- .../src/components/SessionIdHelp.tsx | 42 +- .../src/components/SubagentGraph.tsx | 17 +- src/agentsight/dashboard/src/i18n.tsx | 962 +++++++++++++++++- .../dashboard/src/pages/AgentHealthPage.tsx | 276 ++--- .../dashboard/src/pages/AtifViewerPage.tsx | 228 +++-- .../dashboard/src/pages/ConversationList.tsx | 182 ++-- .../src/pages/SecurityObservabilityPage.tsx | 63 +- .../dashboard/src/pages/TokenSavingsPage.tsx | 219 ++-- .../src/pages/security/EventDetailDrawer.tsx | 36 +- .../src/pages/security/EventTable.tsx | 30 +- .../src/pages/security/EventsTab.tsx | 28 +- .../pages/security/OverviewRiskSummary.tsx | 54 +- .../src/pages/security/OverviewTab.tsx | 24 +- .../src/pages/security/RecentEvents.tsx | 16 +- .../src/pages/security/TimelineItem.tsx | 24 +- .../security/TimelineSessionOverview.tsx | 32 +- .../src/pages/security/TimelineTab.tsx | 16 +- .../dashboard/src/pages/security/common.tsx | 28 +- .../dashboard/src/pages/security/utils.ts | 37 +- .../dashboard/src/utils/trajectoryTree.ts | 4 +- src/agentsight/src/server/token_savings.rs | 126 +-- 26 files changed, 1940 insertions(+), 854 deletions(-) diff --git a/src/agentsight/dashboard/src/components/CausalAttributionPanel.tsx b/src/agentsight/dashboard/src/components/CausalAttributionPanel.tsx index d054d3a318..225676871e 100644 --- a/src/agentsight/dashboard/src/components/CausalAttributionPanel.tsx +++ b/src/agentsight/dashboard/src/components/CausalAttributionPanel.tsx @@ -89,6 +89,9 @@ interface CausalAttributionPanelProps { sessionId: string; roundIndex?: number; roundLabel?: string; + /** Whether the selected round only carries the system prompt. Supplied as a + * flag rather than inferred from `roundLabel`, which is localized. */ + isPreambleRound?: boolean; /** "conversation" when the parent page is viewing a conversation_id; unset otherwise. */ idKind?: 'session' | 'conversation'; /** Called when the user clicks a causal node — parent scrolls the trajectory to that step. */ @@ -483,6 +486,7 @@ export const CausalAttributionPanel: React.FC = ({ sessionId, roundIndex, roundLabel, + isPreambleRound = false, idKind, onScrollToStep, }) => { @@ -608,7 +612,7 @@ export const CausalAttributionPanel: React.FC = ({ - {roundLabel === '前置' && ( + {isPreambleRound && (
提示:“前置”轮只包含系统 prompt,没有 agent 决策可分析。建议切到左侧某个“第 N 轮”再发起归因,结果会更有意义。
diff --git a/src/agentsight/dashboard/src/components/EvaluationBadge.tsx b/src/agentsight/dashboard/src/components/EvaluationBadge.tsx index 43882c5648..b3928c36d7 100644 --- a/src/agentsight/dashboard/src/components/EvaluationBadge.tsx +++ b/src/agentsight/dashboard/src/components/EvaluationBadge.tsx @@ -1,5 +1,7 @@ import React from 'react'; import { EvaluationResult } from '../utils/apiClient'; +import { useI18n } from '../i18n'; +import type { MessageKey } from '../i18n'; interface EvaluationBadgeProps { result: Pick | null; @@ -11,26 +13,26 @@ const STYLE_BY_VERDICT = { fail: 'bg-red-50 text-red-700 border-red-200', } as const; -const LABEL_BY_VERDICT = { - pass: '通过', - warn: '需复核', - fail: '未通过', -} as const; +const VERDICT_LABEL_KEY: Record = { + pass: 'comp.eval.pass', + warn: 'comp.eval.review', + fail: 'comp.eval.fail', +}; export const EvaluationBadge: React.FC = ({ result }) => { + const { t } = useI18n(); if (!result) return null; const style = STYLE_BY_VERDICT[result.verdict as keyof typeof STYLE_BY_VERDICT] ?? 'bg-gray-50 text-gray-700 border-gray-200'; - const label = - LABEL_BY_VERDICT[result.verdict as keyof typeof LABEL_BY_VERDICT] ?? - result.verdict; + const labelKey = VERDICT_LABEL_KEY[result.verdict]; + const label = labelKey ? t(labelKey) : result.verdict; return ( {label} {Math.round(result.score * 100)} diff --git a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx index 552aecdbdd..99e0b150bc 100644 --- a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx +++ b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx @@ -8,6 +8,8 @@ import { evaluateConversation, } from '../utils/apiClient'; import { EvaluationBadge } from './EvaluationBadge'; +import { useI18n } from '../i18n'; +import type { MessageKey } from '../i18n'; interface EvaluationPanelProps { conversationId: string; @@ -21,6 +23,7 @@ export const EvaluationPanel: React.FC = ({ onResult, }) => { const navigate = useNavigate(); + const { t } = useI18n(); const [result, setResult] = useState(initialResult); const [expanded, setExpanded] = useState(false); const [loading, setLoading] = useState(false); @@ -43,7 +46,7 @@ export const EvaluationPanel: React.FC = ({ if (err instanceof EvaluationNotReadyError) { setPendingCount(err.pendingCallCount); } else { - setError(err instanceof Error ? err.message : '质量评估失败'); + setError(err instanceof Error ? err.message : t('comp.eval.evaluationFailed')); } } finally { setLoading(false); @@ -65,7 +68,7 @@ export const EvaluationPanel: React.FC = ({ className="rounded border border-blue-200 bg-white px-1.5 py-0.5 text-[11px] text-blue-700 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50" title={ref.id} > - {evidenceLabel(ref.label)} + {evidenceLabel(ref.label, t)} ); })} @@ -79,19 +82,19 @@ export const EvaluationPanel: React.FC = ({
- 质量评估 + {t('comp.eval.qualityEvaluation')}
{result ? (
-

{summaryText(result)}

+

{summaryText(result, t)}

- 根因:{rootCauseLabel(result.root_cause)} + {t('comp.eval.rootCausePrefix')} {rootCauseLabel(result.root_cause, t)}

-

{recommendedActionText(result)}

+

{recommendedActionText(result, t)}

) : ( -

暂无质量评估结果。

+

{t('comp.eval.noResult')}

)}
{pendingCount !== null && (
- {pendingCount} 个 LLM 调用仍未完成。 + {t('comp.eval.pendingCalls', { n: pendingCount })}
)} {result?.metadata.evaluated_with_pending && (
- 评估时仍有 {result.metadata.pending_call_count} 个 LLM 调用未完成。 + {t('comp.eval.pendingWhenRan', { n: result.metadata.pending_call_count })}
)} @@ -134,17 +137,17 @@ export const EvaluationPanel: React.FC = ({ onClick={() => setExpanded((value) => !value)} className="text-xs font-medium text-blue-700 hover:text-blue-900" > - {expanded ? '收起详情' : '查看详情'} + {expanded ? t('comp.eval.collapseDetails') : t('comp.eval.viewDetails')} {expanded && (
-

评估维度

+

{t('comp.eval.dimensions')}

{result.dimensions.map((dimension) => (
- {dimensionLabel(dimension.name)} + {dimensionLabel(dimension.name, t)} {Math.round(dimension.score * 100)} @@ -156,18 +159,18 @@ export const EvaluationPanel: React.FC = ({
-

问题发现

+

{t('comp.eval.findings')}

{result.findings.length === 0 ? ( -

未发现问题。

+

{t('comp.eval.noFindings')}

) : ( result.findings.map((finding, index) => (
- {findingLabel(finding.code)} + {findingLabel(finding.code, t)} - {severityLabel(finding.severity)} + {severityLabel(finding.severity, t)}

{findingMessageText(finding.message)}

{renderEvidenceLinks(finding.evidence_refs)} @@ -184,6 +187,8 @@ export const EvaluationPanel: React.FC = ({ ); }; +type TFunc = (key: MessageKey, params?: Record) => string; + function evidencePath(ref: EvaluationRef): string | null { if (!ref.deeplink) return null; @@ -197,145 +202,110 @@ function evidencePath(ref: EvaluationRef): string | null { return query ? `${ref.deeplink.route}?${query}` : ref.deeplink.route; } -function summaryText(result: EvaluationResult): string { +function summaryText(result: EvaluationResult, t: TFunc): string { if (result.verdict === 'pass') { - return '会话已完成,未发现确定性的质量问题。'; + return t('comp.eval.summary.pass'); } if (result.verdict === 'warn') { - return `当前会话可用,但需要复核:${rootCauseLabel(result.root_cause)}。`; + return t('comp.eval.summary.warn', { cause: rootCauseLabel(result.root_cause, t) }); } - return `质量评估未通过,主要原因:${rootCauseLabel(result.root_cause)}。`; + return t('comp.eval.summary.fail', { cause: rootCauseLabel(result.root_cause, t) }); } -function recommendedActionText(result: EvaluationResult): string { +const ACTION_KEY: Record = { + none: 'comp.eval.action.none', + no_final_answer: 'comp.eval.action.no_final_answer', + interrupted_main_call: 'comp.eval.action.interrupted_main_call', + agent_crash: 'comp.eval.action.agent_crash', + runtime_error: 'comp.eval.action.runtime_error', + tool_failure: 'comp.eval.action.tool_failure', + safety_risk: 'comp.eval.action.safety_risk', + loop_detected: 'comp.eval.action.loop_detected', + excessive_cost: 'comp.eval.action.excessive_cost', + partial_snapshot: 'comp.eval.action.partial_snapshot', +}; + +function recommendedActionText(result: EvaluationResult, t: TFunc): string { if (result.verdict === 'pass') { - return '暂无需要立即处理的动作。'; + return t('comp.eval.action.noActionRequired'); } - const actions: Record = { - none: '复核告警项和支撑证据。', - no_final_answer: '检查最后一次 LLM 调用和服务端响应解析。', - interrupted_main_call: '检查中断证据,修复运行稳定性后再重试会话。', - agent_crash: '重试前先检查 Agent 健康状态和崩溃诊断。', - runtime_error: '检查模型服务错误、网络稳定性和重试行为。', - tool_failure: '检查失败的工具调用和工具响应解析。', - safety_risk: '重新运行 Agent 前先复核安全相关发现。', - loop_detected: '检查重复调用并收紧停止条件。', - excessive_cost: '复核提示词、工具输出和 Token 节省空间。', - partial_snapshot: '等待 pending 调用完成,或保留强制评估标记。', - }; - - return actions[result.root_cause] ?? result.recommended_action ?? result.root_cause; + const actionKey = ACTION_KEY[result.root_cause]; + return actionKey ? t(actionKey) : (result.recommended_action ?? result.root_cause); } -function rootCauseLabel(value: string): string { - const labels: Record = { - none: '未发现明确根因', - no_final_answer: '未生成最终回答', - interrupted_main_call: '主调用被中断', - agent_crash: 'Agent 崩溃', - runtime_error: '运行时错误', - tool_failure: '工具调用失败', - safety_risk: '安全风险', - loop_detected: '疑似循环调用', - excessive_cost: '成本过高', - partial_snapshot: '快照未完成', - }; +const ROOT_CAUSE_KEY: Record = { + none: 'comp.eval.cause.none', + no_final_answer: 'comp.eval.cause.no_final_answer', + interrupted_main_call: 'comp.eval.cause.interrupted_main_call', + agent_crash: 'comp.eval.cause.agent_crash', + runtime_error: 'comp.eval.cause.runtime_error', + tool_failure: 'comp.eval.cause.tool_failure', + safety_risk: 'comp.eval.cause.safety_risk', + loop_detected: 'comp.eval.cause.loop_detected', + excessive_cost: 'comp.eval.cause.excessive_cost', + partial_snapshot: 'comp.eval.cause.partial_snapshot', +}; - return labels[value] ?? value; +function rootCauseLabel(value: string, t: TFunc): string { + const key = ROOT_CAUSE_KEY[value]; + return key ? t(key) : value; } -function dimensionLabel(value: string): string { - const labels: Record = { - completion: '完成度', - runtime_health: '运行健康', - tool_use: '工具使用', - efficiency: '效率', - safety: '安全', - }; +const DIMENSION_KEY: Record = { + completion: 'comp.eval.dim.completion', + runtime_health: 'comp.eval.dim.runtime_health', + tool_use: 'comp.eval.dim.tool_use', + efficiency: 'comp.eval.dim.efficiency', + safety: 'comp.eval.dim.safety', +}; - return labels[value] ?? value; +function dimensionLabel(value: string, t: TFunc): string { + const key = DIMENSION_KEY[value]; + return key ? t(key) : value; } +// reasonText and findingMessageText are identity-mapping pass-throughs for +// backend-provided English reason/message strings. function reasonText(value: string): string { - const labels: Record = { - 'No usable assistant output was captured.': '未捕获到可用的助手输出。', - 'A usable output exists.': '已捕获可用输出。', - 'A usable output exists, but the snapshot still has pending calls.': '已捕获可用输出,但快照仍有未完成调用。', - 'A usable assistant output was captured.': '已捕获可用的助手输出。', - 'One or more LLM calls were interrupted.': '一个或多个 LLM 调用被中断。', - 'Unresolved interruption signals were captured for this conversation.': '当前会话存在未解决的中断信号。', - 'The snapshot contains pending calls and may still change.': '快照包含未完成调用,结果仍可能变化。', - 'No runtime interruption was detected.': '未检测到运行时中断。', - 'Tool output contains deterministic error signals.': '工具输出包含确定性错误信号。', - 'The conversation required an unusually large number of LLM calls.': '当前会话的 LLM 调用次数异常偏高。', - 'No deterministic tool failure was detected.': '未检测到确定性工具故障。', - 'Token usage or call count is unusually high for a single conversation.': '单个会话的 Token 用量或调用次数异常偏高。', - 'Token usage or call count is elevated for a single conversation.': '单个会话的 Token 用量或调用次数偏高。', - 'Token usage and call count are within normal bounds.': 'Token 用量和调用次数处于正常范围。', - 'Safety-related interruption signal was captured.': '捕获到安全相关中断信号。', - 'No safety-specific signal was available or triggered.': '未发现安全专项信号触发。', - }; - - return labels[value] ?? value; + return value; } -function findingLabel(value: string): string { - const labels: Record = { - no_final_answer: '未生成最终回答', - interrupted_main_call: '主调用被中断', - partial_snapshot: '快照未完成', - tool_failure: '工具调用失败', - loop_detected: '疑似循环调用', - llm_error: 'LLM 错误', - sse_truncated: 'SSE 流截断', - network_timeout: '网络超时', - service_unavailable: '服务不可用', - agent_crash: 'Agent 崩溃', - }; +const FINDING_KEY: Record = { + no_final_answer: 'comp.eval.finding.no_final_answer', + interrupted_main_call: 'comp.eval.finding.interrupted_main_call', + partial_snapshot: 'comp.eval.finding.partial_snapshot', + tool_failure: 'comp.eval.finding.tool_failure', + loop_detected: 'comp.eval.finding.loop_detected', + llm_error: 'comp.eval.finding.llm_error', + sse_truncated: 'comp.eval.finding.sse_truncated', + network_timeout: 'comp.eval.finding.network_timeout', + service_unavailable: 'comp.eval.finding.service_unavailable', + agent_crash: 'comp.eval.finding.agent_crash', +}; - return labels[value] ?? INTERRUPTION_TYPE_CN[value] ?? value; +function findingLabel(value: string, t: TFunc): string { + const key = FINDING_KEY[value]; + if (key) return t(key); + return INTERRUPTION_TYPE_CN[value] ?? value; } function findingMessageText(value: string): string { - const labels: Record = { - 'The conversation has no usable assistant output.': '会话没有可用的助手输出。', - 'An LLM call was interrupted before normal completion.': 'LLM 调用在正常完成前被中断。', - 'Evaluation was forced while LLM calls were still pending.': '仍有 LLM 调用未完成时执行了强制评估。', - 'Evaluation was forced while calls were pending.': '仍有调用未完成时执行了强制评估。', - 'An unresolved interruption was recorded for this conversation.': '当前会话存在未解决的中断记录。', - 'Tool output contains an error-like signal.': '工具输出包含疑似错误信号。', - 'The conversation used many LLM calls and may need loop inspection.': '会话使用了较多 LLM 调用,可能需要检查循环行为。', - }; - - return labels[value] ?? value; + return value; } -function severityLabel(value: string): string { - const labels: Record = { - critical: '严重', - high: '高', - medium: '中', - low: '低', - }; +const SEVERITY_KEY: Record = { + critical: 'common.critical', + high: 'common.high', + medium: 'common.medium', + low: 'common.low', +}; - return labels[value] ?? value; +function severityLabel(value: string, t: TFunc): string { + const key = SEVERITY_KEY[value]; + return key ? t(key) : value; } -function evidenceLabel(value: string): string { - const labels: Record = { - 'Assistant output': '助手输出', - 'No output': '无输出', - 'Interrupted LLM call': '中断的 LLM 调用', - 'Interrupted call': '中断调用', - 'Pending call': '未完成调用', - 'Tool failure signal': '工具故障信号', - 'Repeated calls': '重复调用', - 'High cost': '高成本', - 'Elevated cost': '成本偏高', - 'Pending snapshot': '未完成快照', - 'Tool failure': '工具故障', - }; - - return labels[value] ?? findingLabel(value); +function evidenceLabel(value: string, t: TFunc): string { + return findingLabel(value, t); } diff --git a/src/agentsight/dashboard/src/components/InterruptionBadge.tsx b/src/agentsight/dashboard/src/components/InterruptionBadge.tsx index d524878401..f011de7388 100644 --- a/src/agentsight/dashboard/src/components/InterruptionBadge.tsx +++ b/src/agentsight/dashboard/src/components/InterruptionBadge.tsx @@ -10,7 +10,8 @@ import React from 'react'; import type { InterruptionSeverity, InterruptionTypeDetail } from '../utils/apiClient'; -import { INTERRUPTION_TYPE_CN } from '../utils/apiClient'; +import { useI18n, interruptionTypeKey } from '../i18n'; +import type { MessageKey } from '../i18n'; const SEVERITY_STYLES: Record = { critical: 'bg-red-600 text-white', @@ -19,21 +20,34 @@ const SEVERITY_STYLES: Record = { low: 'bg-blue-400 text-white', }; -const SEVERITY_LABEL: Record = { - critical: '严重', - high: '重要', - medium: '中等', - low: '轻微', +const SEVERITY_LABEL_KEY: Record = { + critical: 'common.critical', + high: 'common.high', + medium: 'common.medium', + low: 'common.low', }; const SEVERITY_ORDER: InterruptionSeverity[] = ['critical', 'high', 'medium', 'low']; +/** Returns the localized label for a severity level. */ +function severityLabel(sev: InterruptionSeverity, t: (key: MessageKey) => string): string { + return t(SEVERITY_LABEL_KEY[sev]) ?? sev; +} + /** Build tooltip lines from type details for a given severity. */ -function buildTypeTooltipLines(types: InterruptionTypeDetail[], severity: string): string[] { +function buildTypeTooltipLines( + types: InterruptionTypeDetail[], + severity: string, + t: (key: MessageKey, params?: Record) => string, +): string[] { return types - .filter((t) => t.severity === severity) + .filter((detail) => detail.severity === severity) .sort((a, b) => b.count - a.count) - .map((t) => `${INTERRUPTION_TYPE_CN[t.interruption_type] ?? t.interruption_type}: ${t.count} 次`); + .map((detail) => { + const typeKey = interruptionTypeKey(detail.interruption_type); + const typeLabel = typeKey ? t(typeKey) : detail.interruption_type; + return t('comp.interrupt.typeCount', { type: typeLabel, count: detail.count }); + }); } /** CSS tooltip positioned above the badge. */ @@ -65,6 +79,8 @@ interface Props { } export const InterruptionBadge: React.FC = ({ count, severity, bySeverity, types, title, onClick }) => { + const { t } = useI18n(); + // Detailed mode: render one badge per non-zero severity if (bySeverity) { const badges = SEVERITY_ORDER @@ -72,8 +88,8 @@ export const InterruptionBadge: React.FC = ({ count, severity, bySeverity .map((sev) => { const cnt = bySeverity[sev]; const style = SEVERITY_STYLES[sev]; - const label = SEVERITY_LABEL[sev]; - const lines = types ? buildTypeTooltipLines(types, sev) : [`${cnt} ${label}`]; + const label = severityLabel(sev, t); + const lines = types ? buildTypeTooltipLines(types, sev, t) : [`${cnt} ${label}`]; return ( = ({ count, severity, bySeverity if (!count || count === 0) return null; const sev = severity ?? 'medium'; const style = SEVERITY_STYLES[sev] ?? SEVERITY_STYLES.medium; - const label = SEVERITY_LABEL[sev] ?? sev.toUpperCase(); + const label = severityLabel(sev, t); const lines = title ? [title] : [`${count} ${label}`]; return ( diff --git a/src/agentsight/dashboard/src/components/InterruptionPanel.tsx b/src/agentsight/dashboard/src/components/InterruptionPanel.tsx index b885850108..8ea3c68212 100644 --- a/src/agentsight/dashboard/src/components/InterruptionPanel.tsx +++ b/src/agentsight/dashboard/src/components/InterruptionPanel.tsx @@ -13,6 +13,8 @@ import { fetchConversationInterruptions, resolveInterruption, } from '../utils/apiClient'; +import { useI18n, useLocaleTag } from '../i18n'; +import type { MessageKey } from '../i18n'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -23,16 +25,16 @@ const SEVERITY_DOT: Record = { low: 'bg-blue-400', }; -const TYPE_LABELS: Record = { - llm_error: 'LLM Error', - sse_truncated: 'SSE Truncated', - agent_crash: 'Agent Crash', - token_limit: 'Token Limit', - context_overflow: 'Context Overflow', +const TYPE_LABEL_KEY: Record = { + llm_error: 'comp.interrupt.type.llmError', + sse_truncated: 'comp.interrupt.type.sseTruncated', + agent_crash: 'comp.interrupt.type.agentCrash', + token_limit: 'comp.interrupt.type.tokenLimit', + context_overflow: 'comp.interrupt.type.contextOverflow', }; -function formatNs(ns: number): string { - return new Date(ns / 1_000_000).toLocaleString(); +function formatNs(ns: number, locale: string): string { + return new Date(ns / 1_000_000).toLocaleString(locale); } function parseDetail(raw: string | null): React.ReactNode { @@ -57,17 +59,18 @@ interface RowProps { } const InterruptionRow: React.FC = ({ event, onResolved }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); const [expanded, setExpanded] = useState(false); const [resolving, setResolving] = useState(false); const [resolveErr, setResolveErr] = useState(null); const dotStyle = SEVERITY_DOT[event.severity as InterruptionSeverity] ?? 'bg-gray-400'; - const typeLabel = TYPE_LABELS[event.interruption_type] ?? event.interruption_type; + const typeKey = TYPE_LABEL_KEY[event.interruption_type]; + const typeLabel = typeKey ? t(typeKey) : event.interruption_type; const handleResolve = async () => { - const confirmed = window.confirm( - '标记为已处理后,此中断事件将不再计入未处理统计(badge 数字将减少)。\n\n确认标记为已处理吗?' - ); + const confirmed = window.confirm(t('comp.interrupt.markResolvedConfirm')); if (!confirmed) return; setResolving(true); setResolveErr(null); @@ -75,7 +78,7 @@ const InterruptionRow: React.FC = ({ event, onResolved }) => { await resolveInterruption(event.interruption_id); onResolved(event); } catch (e: any) { - setResolveErr(e.message ?? '操作失败,请稍后重试'); + setResolveErr(e.message ?? t('comp.interrupt.operationFailed')); } finally { setResolving(false); } @@ -87,22 +90,22 @@ const InterruptionRow: React.FC = ({ event, onResolved }) => {
{typeLabel} - {formatNs(event.occurred_at_ns)} + {formatNs(event.occurred_at_ns, locale)}
@@ -112,7 +115,7 @@ const InterruptionRow: React.FC = ({ event, onResolved }) => { )} {event.call_id && ( -
call: {event.call_id}
+
{t('comp.interrupt.callLabel', { id: event.call_id })}
)} {expanded && ( @@ -143,6 +146,7 @@ interface Props { } export const InterruptionPanel: React.FC = ({ sessionId, conversationId, onClose, onResolvedEvent }) => { + const { t } = useI18n(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -161,11 +165,11 @@ export const InterruptionPanel: React.FC = ({ sessionId, conversationId, } setEvents(data); } catch (e: any) { - setError(e.message ?? 'Failed to load interruptions'); + setError(e.message ?? t('comp.interrupt.failedToLoad')); } finally { setLoading(false); } - }, [sessionId, conversationId]); + }, [sessionId, conversationId, t]); useEffect(() => { void load(); }, [load]); @@ -188,10 +192,10 @@ export const InterruptionPanel: React.FC = ({ sessionId, conversationId, {/* Header */}
-

Interruptions

+

{t('comp.interrupt.interruptions')}

{!loading && (

- {unresolvedCount} 条未处理 + {t('comp.interrupt.unresolvedCount', { n: unresolvedCount })}

)}
@@ -199,7 +203,7 @@ export const InterruptionPanel: React.FC = ({ sessionId, conversationId, @@ -209,13 +213,13 @@ export const InterruptionPanel: React.FC = ({ sessionId, conversationId, {/* Body */}
{loading && ( -

Loading…

+

{t('common.loading')}

)} {error && (

{error}

)} {!loading && !error && events.length === 0 && ( -

No interruption events recorded for this session.

+

{t('comp.interrupt.noEvents')}

)} {events.map(e => ( diff --git a/src/agentsight/dashboard/src/components/SessionIdHelp.tsx b/src/agentsight/dashboard/src/components/SessionIdHelp.tsx index 3afb77caaa..29bbbdee42 100644 --- a/src/agentsight/dashboard/src/components/SessionIdHelp.tsx +++ b/src/agentsight/dashboard/src/components/SessionIdHelp.tsx @@ -1,20 +1,22 @@ import React, { useEffect, useRef, useState } from 'react'; +import { useI18n } from '../i18n'; /** - * Session ID 用法说明小图标。 + * Small help icon explaining Session ID usage. * - * 设计要点: - * - 自定义 tooltip 而非原生 `title`,原生要等约 1 秒才弹出,这里立即响应。 - * - tooltip 用 `position: fixed` + `getBoundingClientRect` 定位,逃出表格父容器 - * `overflow-hidden` 的裁剪。 - * - 鼠标从 `?` 移出后给 100ms 宽限期允许进入卡片本身;进入卡片时取消计时器, - * 保持开启;从卡片完全离开后才真正关闭,避免抖动闪烁。 - * - 仅承载「说明」语义,不承载「跳转」——使用入口由顶部 NavBar 的 - * 「🔍 ATIF 查看器」承担,避免一个 `?` 同时背负两种不一致的点击语义。 - * - 组件卸载时清理未触发的 setTimeout,防止 React unmounted-component setState - * 告警。 + * Design notes: + * - Custom tooltip instead of native `title` (native waits ~1s; this shows instantly). + * - Tooltip uses `position: fixed` + `getBoundingClientRect` to escape the table + * parent's `overflow-hidden` clipping. + * - After the pointer leaves the `?`, allow a 100ms grace period to enter the card; + * entering the card cancels the timer and keeps it open; it only closes once the + * pointer fully leaves the card, avoiding flicker. + * - Carries only "help" semantics, not navigation — the entry point lives in the + * top NavBar's "🔍 ATIF Viewer", so one `?` never bears two inconsistent click meanings. + * - Cleans up pending setTimeouts on unmount to avoid React setState-after-unmount warnings. */ export const SessionIdHelp: React.FC = () => { + const { t } = useI18n(); const [open, setOpen] = useState(false); const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const anchorRef = useRef(null); @@ -41,7 +43,7 @@ export const SessionIdHelp: React.FC = () => { hideTimerRef.current = setTimeout(() => setOpen(false), 100); }; - // 卸载时清理可能挂起的关闭计时器,避免 setState-after-unmount 告警。 + // Clean up pending close timers on unmount to avoid setState-after-unmount warnings. useEffect(() => { return () => { if (hideTimerRef.current) { @@ -56,7 +58,7 @@ export const SessionIdHelp: React.FC = () => { { style={{ top: pos.top, left: pos.left, position: 'fixed' }} className="z-50 w-72 rounded-md bg-gray-900 text-white text-[11px] leading-relaxed normal-case tracking-normal px-3 py-2 shadow-lg" > -
Session ID 用法
-
唯一标识一次 Agent 会话。
-
用途:
-
① 排查问题时在日志里过滤会话
-
② 通过 agentsight CLI / API 检索会话详情
-
③ 在「🔍 ATIF 查看器」页面粘入 ID 查看完整 trace
+
{t('comp.sessionIdHelp.title')}
+
{t('comp.sessionIdHelp.identifies')}
+
{t('comp.sessionIdHelp.uses')}
+
{t('comp.sessionIdHelp.use1')}
+
{t('comp.sessionIdHelp.use2')}
+
{t('comp.sessionIdHelp.use3')}
- 点击右侧「复制」后,可在顶部导航栏「🔍 ATIF 查看器」中粘贴查询。 + {t('comp.sessionIdHelp.copyHint')}
)} diff --git a/src/agentsight/dashboard/src/components/SubagentGraph.tsx b/src/agentsight/dashboard/src/components/SubagentGraph.tsx index 017706651c..2c627fc825 100644 --- a/src/agentsight/dashboard/src/components/SubagentGraph.tsx +++ b/src/agentsight/dashboard/src/components/SubagentGraph.tsx @@ -5,6 +5,7 @@ import React from 'react'; import type { TrajNode, PositionedNode } from '../utils/trajectoryTree'; import { layoutTree, pathKeys, encodeNodePath, NODE_W, NODE_H } from '../utils/trajectoryTree'; +import { useI18n } from '../i18n'; interface SubagentGraphProps { root: TrajNode; @@ -29,6 +30,7 @@ const NodeBox: React.FC<{ isOnPath: boolean; onSelect: () => void; }> = ({ positioned, isSelected, isOnPath, onSelect }) => { + const { t } = useI18n(); const { node, x, y } = positioned; const isExternal = !!node.externalSessionId; @@ -48,7 +50,11 @@ const NodeBox: React.FC<{ } }} > - {node.detail ?? node.label} + + {isExternal + ? t('comp.externalTrajectoryDetail', { id: node.externalSessionId ?? '' }) + : node.detail ?? node.label} + {isExternal - ? '外部轨迹 ↗' - : `${node.stepCount} 步${node.promptTokens > 0 ? ` · ${fmtTokens(node.promptTokens)} in` : ''}`} + ? t('comp.externalTrajectory') + : `${t('comp.subagentSteps', { n: node.stepCount })}${node.promptTokens > 0 ? ` · ${fmtTokens(node.promptTokens)} in` : ''}`} ); }; export const SubagentGraph: React.FC = ({ root, selectedPath, onSelect }) => { + const { t } = useI18n(); const layout = React.useMemo(() => layoutTree(root), [root]); const onPath = React.useMemo(() => pathKeys(selectedPath), [selectedPath]); const selectedKey = encodeNodePath(selectedPath); @@ -87,9 +94,9 @@ export const SubagentGraph: React.FC = ({ root, selectedPath return (

- 🤖 子代理拓扑 + {t('comp.subagentTopology')} - 共 {subagentCount} 个子代理 · 点击节点在下方查看其轨迹 + {t('comp.subagentCount', { n: subagentCount })}

diff --git a/src/agentsight/dashboard/src/i18n.tsx b/src/agentsight/dashboard/src/i18n.tsx index 777d7f689a..424f24c79f 100644 --- a/src/agentsight/dashboard/src/i18n.tsx +++ b/src/agentsight/dashboard/src/i18n.tsx @@ -14,7 +14,13 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number]; const DEFAULT_LOCALE: Locale = 'en-US'; const LOCALE_STORAGE_KEY = 'agentsight.locale'; +// ─── Message catalog ────────────────────────────────────────────────────────── +// Keys are flat dot-notation strings. Add new keys here first, then use them +// in components via `t('section.key')`. +// Placeholders use {name} syntax, resolved by the `t` function when `params` is passed. + const enUSMessages = { + // ── App / Nav / Login (existing) ── 'app.title': 'Agent Observability', 'app.loading': 'Loading...', 'language.label': 'Language', @@ -41,6 +47,470 @@ const enUSMessages = { 'login.tokenHintSuffix': ' to view your token.', 'login.fullTokenHintPrefix': 'Or use ', 'login.fullTokenHintSuffix': ' to show the complete value.', + + // ── Common / shared ── + 'common.loading': 'Loading...', + 'common.copy': 'Copy', + 'common.copied': '✓ Copied', + 'common.copyFullId': 'Copy full ID', + 'common.details': 'Details', + 'common.collapse': 'Collapse', + 'common.expandAll': 'Expand all →', + 'common.collapseAll': '← Collapse', + 'common.refresh': 'Refresh', + 'common.query': 'Query', + 'common.querying': 'Querying...', + 'common.agent': 'Agent', + 'common.allAgents': 'All Agents', + 'common.startTime': 'Start Time', + 'common.endTime': 'End Time', + 'common.last1h': 'Last 1h', + 'common.last6h': 'Last 6h', + 'common.last24h': 'Last 24h', + 'common.last7d': 'Last 7d', + 'common.noData': 'No data', + 'common.prev': 'Prev', + 'common.next': 'Next', + 'common.first': 'First', + 'common.last': 'Last', + 'common.page': 'Page {cur}/{total}', + 'common.perPage': 'Per page', + 'common.events': '{n} events', + 'common.steps': '{n} steps', + 'common.input': 'Input', + 'common.output': 'Output', + 'common.total': 'Total', + 'common.saved': 'Saved', + 'common.original': 'Original', + 'common.optimized': 'Optimized', + 'common.noChange': 'No change', + 'common.linesRemoved': '-{n} lines removed', + 'common.linesAdded': '+{n} lines added', + 'common.justNow': 'just now', + 'common.secondsAgo': '{n}s ago', + 'common.minutesAgo': '{n}m ago', + 'common.hoursAgo': '{n}h ago', + 'common.allTypes': 'All types', + 'common.allSeverities': 'All severities', + 'common.unresolvedOnly': 'Unresolved only', + 'common.resolved': 'Resolved', + 'common.unresolved': 'Unresolved', + 'common.resolve': 'Resolve', + 'common.time': 'Time', + 'common.type': 'Type', + 'common.severity': 'Severity', + 'common.session': 'Session', + 'common.conversation': 'Conversation', + 'common.status': 'Status', + 'common.actions': 'Actions', + 'common.critical': 'Critical', + 'common.high': 'High', + 'common.medium': 'Medium', + 'common.low': 'Low', + 'common.error': 'Error', + 'common.retry': 'Retry', + 'common.close': 'Close', + 'common.inOut': '{in} in / {out} out', + 'common.tokens': '{n} tokens', + 'common.min': 'min', + 'common.sec': 's', + + // ── AgentHealthPage ── + 'ah.agentDashboard': 'Agent Dashboard', + 'ah.agentDashboardTooltip': 'Monitors the health of local AI agent processes. Crash records are shown only when a process crashed and affected an in-flight LLM conversation; normally-exiting processes are not displayed.', + 'ah.crashed': '{n} crashed', + 'ah.hungCount': '{n} hung', + 'ah.healthyRatio': '{healthy}/{total} healthy', + 'ah.lastScan': 'Last scan: {time}', + 'ah.noAgents': 'No agents discovered', + 'ah.deleteFailed': 'Delete failed: {msg}', + 'ah.restartSucceeded': '✅ Restart succeeded, new PID: {pid}; waiting for process to come online...', + 'ah.restartFailed': 'Restart failed: {msg}', + 'ah.interruptionEvents': 'Interruption Events', + 'ah.unresolvedCount': '{n} unresolved', + 'ah.noInterruptionEvents': 'No interruption events under the current filters', + 'ah.markResolvedConfirm': 'Once marked as resolved, this interruption will no longer count toward unresolved stats.\n\nMark as resolved?', + 'ah.markFailed': 'Mark failed: {msg}', + 'ah.noDetails': 'No details', + 'ah.relatedProcesses': 'Related processes ({n})', + 'ah.orphanedProcesses': 'Orphaned related processes ({n})', + 'ah.removeNow': 'Remove now', + 'ah.restarting': 'Restarting...', + 'ah.restartProcess': 'Restart process', + 'ah.gateway': 'Gateway', + 'ah.client': 'Client', + 'ah.worker': 'Worker', + 'ah.running': 'Running', + 'ah.untilAutoRemoval': '{time} until auto-removal', + 'ah.removingSoon': 'Removing soon', + 'ah.last1Hour': 'Last 1 hour', + 'ah.last24Hours': 'Last 24 hours', + 'ah.last7Days': 'Last 7 days', + 'ah.status.healthy': 'Healthy', + 'ah.status.unhealthy': 'Port unresponsive', + 'ah.status.hung': 'Hung', + 'ah.status.unknown': 'Pending check', + 'ah.status.noPort': 'Client process', + 'ah.status.offline': 'Crashed', + 'ah.tooltip.healthy': 'Service listens on its port and HTTP probe succeeds', + 'ah.tooltip.unhealthy': 'Port refuses connections; a restart may be needed', + 'ah.tooltip.hung': 'Port accepts connections but HTTP probe times out; process may be stuck', + 'ah.tooltip.unknown': 'First health check not yet completed', + 'ah.tooltip.noPort': 'TUI / child process without a service port (normal)', + 'ah.tooltip.offline': 'Process crashed and affected an in-flight LLM conversation; auto-removed after 5 minutes', + 'ah.tooltip.running': 'Single-process agent without a service port; running normally', + 'ah.requestFailed': 'Request failed', + 'ah.failedToLoad': 'Failed to load interruption events', + 'ah.callLabel': 'call: {id}', + 'ah.copiedValue': 'Copied: {value}', + 'ah.copyFailedValue': 'Copy failed: {value}', + 'ah.markResolvedTitle': 'Mark as resolved; no longer counted in unresolved stats', + + // ── TokenSavingsPage ── + 'ts.actualConsumption': 'Actual Token Consumption', + 'ts.actualConsumptionTip': 'Tokens actually billed by the LLM API, already reflecting Tokenless optimization', + 'ts.savedTokens': 'Saved Tokens', + 'ts.savedTokensTip': 'Baseline = tokens that would have been consumed without Tokenless optimization. Saved = baseline - actual consumption', + 'ts.baseline': 'Baseline: {n}', + 'ts.savingsRate': 'Savings Rate', + 'ts.savingsRateFormula': '= Saved / Baseline × 100%', + 'ts.excellent': 'Excellent', + 'ts.good': 'Good', + 'ts.needsWork': 'Needs Work', + 'ts.baselineConsumption': 'Baseline consumption', + 'ts.actual': 'Actual', + 'ts.saved': 'Saved', + 'ts.noOptimizationRecords': 'No optimization records found', + 'ts.selectTimeRange': 'Select a time range and click "Query"', + 'ts.viewSavings': 'View token savings effects', + 'ts.optimizationTips': '🎯 Optimization Tips', + 'ts.savingsTop5': '📊 Savings Top 5 (by compounded savings)', + 'ts.category': 'Category', + 'ts.savingsStrategy': 'Savings Strategy', + 'ts.before': 'Before', + 'ts.optimizedCol': 'Optimized', + 'ts.savedCol': 'Saved', + 'ts.detailsCol': 'Details', + 'ts.compressionRatio': 'Compression ratio', + 'ts.affectsCalls': 'Affects {n} subsequent calls', + 'ts.compoundedSavings': 'Compounded savings {n} tokens', + 'ts.singleTurn': '(single-turn {pct}%)', + 'ts.sessionId': 'Session ID', + 'ts.inputTokens': 'Input Tokens', + 'ts.outputTokens': 'Output Tokens', + 'ts.savingsRateCol': 'Savings Rate', + 'ts.toolOutput': 'Tool Output', + 'ts.mcpOutput': 'MCP Output', + 'ts.schemaCompression': 'Schema Compression', + 'ts.responseCompression': 'Response Compression', + 'ts.commandRewrite': 'Command Rewrite', + 'ts.toonEncoding': 'TOON Encoding', + 'ts.schemaCompressionTip': 'Slim down tool/MCP interface definitions to shrink context', + 'ts.responseCompressionTip': 'Strip redundant response fields, keep semantically key content', + 'ts.commandRewriteTip': 'Rewrite tool commands into more compact equivalents', + 'ts.toonEncodingTip': 'Convert JSON output into compact TOON table text', + 'ts.savedComparedTooltip': 'Compared against this session\'s baseline consumption', + 'ts.tool': 'Tool', + 'ts.mcp': 'MCP', + 'ts.fetchFailed': 'Failed to fetch token savings', + + // ── AtifViewerPage ── + 'atif.trajectoryViewer': 'Trajectory Viewer', + 'atif.downloadJson': '⬇️ Download JSON', + 'atif.importJson': '📁 Import JSON', + 'atif.bySession': 'By Session', + 'atif.byConversation': 'By Conversation', + 'atif.enterSessionId': 'Enter Session ID...', + 'atif.enterConversationId': 'Enter Conversation ID...', + 'atif.load': 'Load', + 'atif.loading': 'Loading...', + 'atif.enterSessionOrConv': 'Enter a Session or Conversation ID, then click "Load"', + 'atif.orImportLocal': 'or import a local ATIF JSON file', + 'atif.agentInfo': 'Agent Info', + 'atif.name': 'Name', + 'atif.version': 'Version', + 'atif.model': 'Model', + 'atif.toolDefinitions': 'Tool definitions', + 'atif.totalSteps': 'Total Steps', + 'atif.totalInputTokens': 'Total Input Tokens', + 'atif.totalOutputTokens': 'Total Output Tokens', + 'atif.ofWhichCached': 'of which cached: {n}', + 'atif.tokenSavingsComparison': 'Token Savings Comparison', + 'atif.originalTokens': 'Original Tokens (unoptimized)', + 'atif.actualTokens': 'Actual Tokens (optimized)', + 'atif.interactionTrajectory': 'Interaction Trajectory', + 'atif.roundsSteps': '{rounds} rounds · {steps} steps', + 'atif.noStepData': 'No step data in this trajectory', + 'atif.clickRoundToView': 'Click a round on the left to view details', + 'atif.noMessageContent': 'No message content', + 'atif.reasoning': 'Reasoning', + 'atif.toolCall': 'Tool call', + 'atif.observation': 'Observation', + 'atif.noOutputContent': 'No output content', + 'atif.inputLabel': 'Input: {n}', + 'atif.outputLabel': 'Output: {n}', + 'atif.cacheLabel': 'Cache: {n}', + 'atif.optimizedTokens': 'Optimized -{n} tokens ({strategy})', + 'atif.collapseArgs': 'Collapse args', + 'atif.expandArgs': 'Expand args', + 'atif.subagentTrajectory': '🤖 Subagent trajectory', + 'atif.selectSubagentHelp': 'Select this subagent in the topology graph above to view its trajectory', + 'atif.round': 'Round {n}', + 'atif.preamble': 'Preamble', + 'atif.conversationDetails': 'Conversation Details', + 'atif.toolCalls': '🔧 {n} tool calls', + 'atif.system': 'System', + 'atif.user': 'User', + 'atif.agentLabel': 'Agent', + 'atif.jsonParseFailed': 'JSON parse failed, please check the file format', + 'atif.jsonParseFailedNotATIF': 'JSON parse failed: missing schema_version field or not ATIF format', + 'atif.sessionNotFound': 'Session not found: {id} (no eBPF capture records and no collected trajectory)', + 'atif.malformedCollected': 'Malformed collected trajectory: {id}', + 'atif.cannotResolveSub': 'Cannot resolve sub-trajectory reference: missing trajectory_id or trajectory_path', + 'atif.externalNotSupported': 'External sub-trajectory references are not supported yet: {path}', + 'atif.selectSubagentInGraph': 'Select this subagent in the topology graph above to view its trajectory', + 'atif.stepLabel': 'Step {n}', + 'atif.callLabel': 'call: {id}', + 'atif.savedLabel': 'Saved', + 'atif.originalLabel': 'Original', + 'atif.actualLabel': 'Actual', + 'atif.loadFailed': 'Load failed', + + // ── ConversationList ── + 'cl.traceDetails': 'Trace Details', + 'cl.noDataForTrace': 'No data for this trace', + 'cl.loadingTraces': 'Loading traces...', + 'cl.noTraces': 'No traces in this session', + 'cl.noMessageData': 'No message data', + 'cl.conversationId': 'Conversation ID', + 'cl.userQuery': 'User Query', + 'cl.inputTokens': 'Input Tokens', + 'cl.outputTokens': 'Output Tokens', + 'cl.startTime': 'Start Time', + 'cl.actions': 'Actions', + 'cl.qualityEval': 'Quality Eval', + 'cl.interrupts': 'Interrupts', + 'cl.eval': 'Eval', + 'cl.loadFailed': 'Load failed', + 'cl.loadFailedTooltip': 'Failed to load evaluation, click to retry', + 'cl.sessions': 'Sessions', + 'cl.totalInputTokens': 'Total Input Tokens', + 'cl.totalOutputTokens': 'Total Output Tokens', + 'cl.interruptions': 'Interruptions', + 'cl.tokenTimeseries': 'Token Timeseries (Input / Output / Total)', + 'cl.modelTokenTimeseries': 'Model Token Timeseries (Stacked)', + 'cl.noTimeseriesData': 'No timeseries data', + 'cl.noModelTimeseriesData': 'No model timeseries data', + 'cl.sessionId': 'Session ID', + 'cl.agent': 'Agent', + 'cl.model': 'Model', + 'cl.conversations': 'Conversations', + 'cl.savedTokens': 'Saved Tokens', + 'cl.lastActive': 'Last Active', + 'cl.noSessions': 'No sessions in the selected time range', + 'cl.ensureServiceRunning': 'Ensure the agentsight service is running and writing data', + 'cl.unknownModel': 'unknown model', + 'cl.queryFailed': 'Query failed', + 'cl.loadingEllipsis': 'Loading...', + 'cl.totalTokens': 'Total Tokens', + + // ── Security ── + 'sec.securityObservability': 'Security Observability', + 'sec.securityObservabilityDesc': 'Security Observability / agent-sec daemon', + 'sec.refresh': 'Refresh', + 'sec.overview': 'Overview', + 'sec.securityEvents': 'Security Events', + 'sec.fullChainEvents': 'Full-chain Events', + 'sec.currentState': 'Current state is {state}; security data view not loaded.', + 'sec.securityEventDetails': 'Security Event Details', + 'sec.loadingDetails': 'Loading details...', + 'sec.eventNoLongerExists': 'This security event no longer exists.', + 'sec.loadingSecurityStatus': 'Loading security observability status...', + 'sec.statusLoadFailed': 'Failed to load security observability status', + 'sec.refreshStatus': 'Refresh Status', + 'sec.refreshing': 'Refreshing...', + 'sec.daemonReachable': 'daemon reachable', + 'sec.disabled': 'disabled', + 'sec.daemonUnreachable': 'daemon unreachable', + 'sec.storeUnavailable': 'data unavailable', + 'sec.schemaMismatch': 'schema mismatch', + 'sec.ok': 'ok', + 'sec.empty': 'no data', + 'sec.partial': 'partial data', + 'sec.found': 'found', + 'sec.notFound': 'not found', + 'sec.apiRequestFailed': 'Security observability API request failed', + 'sec.sessionOverview': 'Session Overview', + 'sec.sessionOverviewDesc': 'Aggregates security event verdicts for the selected session in the current time range', + 'sec.currentRun': 'Current Run', + 'sec.observabilityEvents': 'Observability Events', + 'sec.securityEventsCount': 'Security Events', + 'sec.nonPassVerdicts': '{n} non-pass verdicts found', + 'sec.allPass': 'All pass', + 'sec.loadingTimeline': 'Loading timeline...', + 'sec.noTimelineData': 'No timeline data for this run.', + 'sec.daemon': 'agent-sec daemon', + 'sec.category': 'Category', + 'sec.result': 'Result', + 'sec.verdict': 'Verdict', + 'sec.session': 'Session', + 'sec.run': 'Run', + 'sec.call': 'Call', + 'sec.toolCall': 'Tool Call', + 'sec.trace': 'Trace', + 'sec.turns': 'Turns', + 'sec.eventType': 'Event Type', + 'sec.sessionId': 'Session ID', + 'sec.runId': 'Run ID', + 'sec.all': 'All', + 'sec.clear': 'Clear', + 'sec.redactedByDaemon': 'Sensitive fields were redacted by the agent-sec daemon.', + 'sec.truncatedBySize': 'Some fields were truncated due to size limits.', + 'sec.totalEvents': 'Total {n}', + 'sec.loadingEvents': 'Loading security events...', + 'sec.noEventsFiltered': 'No security events match the current filters', + 'sec.loadingSummary': 'Loading security summary...', + 'sec.affectedSessions': 'Affected Sessions', + 'sec.affectedRuns': 'Affected Runs', + 'sec.noEventsInRange': 'No security events in the selected range.', + 'sec.noEventsInRangeShort': 'No security events in the selected range', + 'sec.byCategory': 'By Category', + 'sec.byEventType': 'By Event Type', + 'sec.byResult': 'By Result', + 'sec.noCategoryData': 'No category data', + 'sec.noEventTypeData': 'No event type data', + 'sec.noResultData': 'No result data', + 'sec.recentSecurityEvents': 'Recent Security Events', + 'sec.observabilityHook': 'observability {hook}', + 'sec.redacted': 'redacted', + 'sec.truncated': 'truncated', + 'sec.matchReason': 'match {reason}', + 'sec.viewEventDetailsAria': 'View security event details {id}', + 'sec.correlatedEvents': '{n} correlated security events', + 'sec.sessionLabel': 'session {id}', + 'sec.runLabel': 'run {id}', + 'sec.toolLabel': 'tool {id}', + 'sec.metadataMetrics': 'metadata / metrics', + 'sec.selectSessionToAggregate': 'Select a session to aggregate verdicts', + 'sec.aggregatingVerdicts': 'Aggregating verdicts...', + 'sec.verdictAggregationFailed': 'Verdict aggregation failed', + 'sec.noSessionSelected': 'No session selected', + 'sec.noSecurityEventsShort': 'No security events', + 'sec.noVerdictsYet': 'No verdicts yet', + 'sec.allVerdictsPass': 'All verdicts pass', + 'sec.verdictCoverage': 'Based on {n} events with verdicts in the current time range', + 'sec.summaryNoEvents': 'No security events were recorded in the current time range; the security capability found nothing to display.', + 'sec.summaryChecksPerformed': 'The security capability performed {n} checks in the current time range.', + 'sec.noNonPassFound': 'No non-pass verdicts found', + 'sec.summaryAllPass': 'The security capability performed {n} checks in the current time range; all recorded verdicts are pass.', + 'sec.riskVerdictsFound': '{n} risk verdicts found', + 'sec.summaryRiskFound': 'The security capability performed {total} checks in the current time range; {nonPass} verdicts are not pass — risky operations need attention.', + 'sec.verdictsToReview': '{n} verdicts need review', + 'sec.summaryReviewRecommended': 'The security capability performed {total} checks in the current time range; {nonPass} verdicts are not pass — review recommended.', + 'sec.summaryNoVerdictDetails': 'The security capability performed {n} checks in the current time range, but no verdict details are available in the current sample.', + 'sec.capabilityVerdict': 'Security Capability Verdict', + 'sec.checksExecuted': 'Checks Executed', + 'sec.coveringSessionsRuns': 'Covering {sessions} sessions / {runs} runs', + 'sec.riskyOperationRatio': 'Risky Operation Ratio', + 'sec.verdictsNotPass': '{nonPass} / {total} verdicts not pass', + 'sec.needsAttention': 'Needs Attention', + 'sec.riskWarningCount': 'Risk {risk} / Warning {warning}', + 'sec.clusteredByVerdict': 'Clustered by Verdict', + 'sec.withVerdict': '{n} with verdict', + 'sec.noVerdictClusterData': 'No verdict cluster data', + 'sec.whatCapabilityDid': 'What the Capability Did', + 'sec.noCheckActionData': 'No check action data', + 'sec.executionStatus': 'Execution Status', + 'sec.noExecutionStatusData': 'No execution status data', + + // ── Components ── + 'comp.eval.pass': 'Pass', + 'comp.eval.review': 'Review', + 'comp.eval.fail': 'Fail', + 'comp.eval.qualityScore': 'Quality score {n}', + 'comp.eval.evaluationFailed': 'Evaluation failed', + 'comp.eval.qualityEvaluation': 'Quality Evaluation', + 'comp.interrupt.markResolvedConfirm': 'Once marked as resolved, this interruption will no longer count toward unresolved stats (the badge number will decrease).\n\nMark as resolved?', + 'comp.interrupt.operationFailed': 'Operation failed, please retry later', + 'comp.interrupt.markResolvedTitle': 'Mark as resolved; no longer counted in unresolved stats', + 'comp.interrupt.unresolvedCount': '{n} unresolved', + 'comp.interrupt.interruptions': 'Interruptions', + 'comp.sessionIdHelp.ariaLabel': 'Session ID usage help', + 'comp.sessionIdHelp.title': 'Session ID Usage', + 'comp.sessionIdHelp.identifies': 'Uniquely identifies one agent session.', + 'comp.sessionIdHelp.uses': 'Uses:', + 'comp.sessionIdHelp.use1': '① Filter sessions in logs when troubleshooting', + 'comp.sessionIdHelp.use2': '② Look up session details via agentsight CLI / API', + 'comp.sessionIdHelp.use3': '③ Paste the ID in the "🔍 ATIF Viewer" page to see the full trace', + 'comp.sessionIdHelp.copyHint': 'After clicking "Copy" on the right, paste it into the "🔍 ATIF Viewer" in the top nav bar to query.', + 'comp.subagentTopology': '🤖 Subagent Topology', + 'comp.subagentCount': '{n} subagents · click a node to view its trajectory below', + 'comp.externalTrajectory': 'External trajectory ↗', + 'comp.externalTrajectoryDetail': 'External trajectory {id}', + 'comp.subagentSteps': '{n} steps', + + // ── InterruptionPanel ── + 'comp.interrupt.noEvents': 'No interruption events recorded for this session.', + 'comp.interrupt.failedToLoad': 'Failed to load interruptions', + 'comp.interrupt.callLabel': 'call: {id}', + 'comp.interrupt.type.llmError': 'LLM Error', + 'comp.interrupt.type.sseTruncated': 'SSE Truncated', + 'comp.interrupt.type.agentCrash': 'Agent Crash', + 'comp.interrupt.type.tokenLimit': 'Token Limit', + 'comp.interrupt.type.contextOverflow': 'Context Overflow', + + // ── EvaluationPanel ── + 'comp.eval.noResult': 'No evaluation result yet.', + 'comp.eval.startEval': 'Start Evaluation', + 'comp.eval.evaluating': 'Evaluating...', + 'comp.eval.forceEval': 'Force Evaluation', + 'comp.eval.pendingCalls': '{n} LLM calls are still pending.', + 'comp.eval.pendingWhenRan': '{n} LLM calls were still pending when evaluation ran.', + 'comp.eval.collapseDetails': 'Collapse details', + 'comp.eval.viewDetails': 'View details', + 'comp.eval.dimensions': 'Evaluation Dimensions', + 'comp.eval.findings': 'Findings', + 'comp.eval.noFindings': 'No findings.', + 'comp.eval.rootCausePrefix': 'Root cause:', + 'comp.eval.summary.pass': 'The conversation completed with no deterministic quality issues found.', + 'comp.eval.summary.warn': 'The conversation is usable but needs review: {cause}.', + 'comp.eval.summary.fail': 'Evaluation failed, main reason: {cause}.', + 'comp.eval.action.none': 'Review warnings and supporting evidence.', + 'comp.eval.action.noActionRequired': 'No immediate action required.', + 'comp.eval.action.no_final_answer': 'Inspect the last LLM call and server response parsing.', + 'comp.eval.action.interrupted_main_call': 'Check interruption evidence, fix runtime stability, then retry the session.', + 'comp.eval.action.agent_crash': 'Check agent health and crash diagnostics before retrying.', + 'comp.eval.action.runtime_error': 'Check model service errors, network stability, and retry behavior.', + 'comp.eval.action.tool_failure': 'Inspect failed tool calls and tool response parsing.', + 'comp.eval.action.safety_risk': 'Review safety-related findings before re-running the agent.', + 'comp.eval.action.loop_detected': 'Inspect repeated calls and tighten stop conditions.', + 'comp.eval.action.excessive_cost': 'Review prompts, tool outputs, and token savings opportunities.', + 'comp.eval.action.partial_snapshot': 'Wait for pending calls to finish, or keep the forced-evaluation flag.', + 'comp.eval.cause.none': 'No clear root cause found', + 'comp.eval.cause.no_final_answer': 'No final answer produced', + 'comp.eval.cause.interrupted_main_call': 'Main call interrupted', + 'comp.eval.cause.agent_crash': 'Agent crash', + 'comp.eval.cause.runtime_error': 'Runtime error', + 'comp.eval.cause.tool_failure': 'Tool call failure', + 'comp.eval.cause.safety_risk': 'Safety risk', + 'comp.eval.cause.loop_detected': 'Suspected loop calls', + 'comp.eval.cause.excessive_cost': 'Excessive cost', + 'comp.eval.cause.partial_snapshot': 'Snapshot incomplete', + 'comp.eval.dim.completion': 'Completion', + 'comp.eval.dim.runtime_health': 'Runtime Health', + 'comp.eval.dim.tool_use': 'Tool Use', + 'comp.eval.dim.efficiency': 'Efficiency', + 'comp.eval.dim.safety': 'Safety', + 'comp.eval.finding.no_final_answer': 'No final answer', + 'comp.eval.finding.interrupted_main_call': 'Main call interrupted', + 'comp.eval.finding.partial_snapshot': 'Snapshot incomplete', + 'comp.eval.finding.tool_failure': 'Tool failure', + 'comp.eval.finding.loop_detected': 'Suspected loop', + 'comp.eval.finding.llm_error': 'LLM error', + 'comp.eval.finding.sse_truncated': 'SSE truncated', + 'comp.eval.finding.network_timeout': 'Network timeout', + 'comp.eval.finding.service_unavailable': 'Service unavailable', + 'comp.eval.finding.agent_crash': 'Agent crash', } as const; export type MessageKey = keyof typeof enUSMessages; @@ -48,6 +518,7 @@ export type MessageKey = keyof typeof enUSMessages; const messages: Record> = { 'en-US': enUSMessages, 'zh-CN': { + // ── App / Nav / Login ── 'app.title': 'Agent可观测', 'app.loading': '加载中...', 'language.label': '语言', @@ -74,13 +545,479 @@ const messages: Record> = { 'login.tokenHintSuffix': ' 查看令牌。', 'login.fullTokenHintPrefix': '或使用 ', 'login.fullTokenHintSuffix': ' 显示完整令牌。', + + // ── Common ── + 'common.loading': '加载中...', + 'common.copy': '复制', + 'common.copied': '✓ 已复制', + 'common.copyFullId': '复制完整 ID', + 'common.details': '详情', + 'common.collapse': '收起', + 'common.expandAll': '展开全部 →', + 'common.collapseAll': '← 收起', + 'common.refresh': '刷新', + 'common.query': '查询', + 'common.querying': '查询中...', + 'common.agent': 'Agent', + 'common.allAgents': '全部 Agent', + 'common.startTime': '开始时间', + 'common.endTime': '结束时间', + 'common.last1h': '最近 1h', + 'common.last6h': '最近 6h', + 'common.last24h': '最近 24h', + 'common.last7d': '最近 7d', + 'common.noData': '暂无数据', + 'common.prev': '上一页', + 'common.next': '下一页', + 'common.first': '首页', + 'common.last': '末页', + 'common.page': '第 {cur}/{total} 页', + 'common.perPage': '每页', + 'common.events': '{n} 条事件', + 'common.steps': '{n} 步', + 'common.input': '输入', + 'common.output': '输出', + 'common.total': '总计', + 'common.saved': '已节省', + 'common.original': '原始内容', + 'common.optimized': '优化后', + 'common.noChange': '无变更', + 'common.linesRemoved': '-{n} 行移除', + 'common.linesAdded': '+{n} 行新增', + 'common.justNow': '刚刚', + 'common.secondsAgo': '{n} 秒前', + 'common.minutesAgo': '{n} 分钟前', + 'common.hoursAgo': '{n} 小时前', + 'common.allTypes': '全部类型', + 'common.allSeverities': '全部严重级别', + 'common.unresolvedOnly': '仅未处理', + 'common.resolved': '已处理', + 'common.unresolved': '未处理', + 'common.resolve': '处理', + 'common.time': '时间', + 'common.type': '类型', + 'common.severity': '严重级别', + 'common.session': '会话', + 'common.conversation': '对话', + 'common.status': '状态', + 'common.actions': '操作', + 'common.critical': '严重', + 'common.high': '高', + 'common.medium': '中', + 'common.low': '低', + 'common.error': '错误', + 'common.retry': '重试', + 'common.close': '关闭', + 'common.inOut': '{in} in / {out} out', + 'common.tokens': '{n} tokens', + 'common.min': '分钟', + 'common.sec': '秒', + + // ── AgentHealthPage ── + 'ah.agentDashboard': 'Agent 看板', + 'ah.agentDashboardTooltip': '监控本地 AI agent 进程的健康状态。仅当进程崩溃并影响了进行中的 LLM 对话时才显示崩溃记录;正常退出的进程不展示。', + 'ah.crashed': '{n} 已崩溃', + 'ah.hungCount': '{n} 卡住', + 'ah.healthyRatio': '{healthy}/{total} 正常', + 'ah.lastScan': '上次扫描: {time}', + 'ah.noAgents': '未发现 agent', + 'ah.deleteFailed': '删除失败: {msg}', + 'ah.restartSucceeded': '✅ 重启成功,新 PID: {pid},等待进程上线...', + 'ah.restartFailed': '重启失败: {msg}', + 'ah.interruptionEvents': '中断事件', + 'ah.unresolvedCount': '{n} 条未处理', + 'ah.noInterruptionEvents': '当前筛选条件下暂无中断事件', + 'ah.markResolvedConfirm': '标记为已处理后,此中断事件将不再计入未处理统计。\n\n确认标记为已处理吗?', + 'ah.markFailed': '标记失败: {msg}', + 'ah.noDetails': '无详情', + 'ah.relatedProcesses': '关联进程 ({n})', + 'ah.orphanedProcesses': '孤立关联进程 ({n})', + 'ah.removeNow': '立即移除', + 'ah.restarting': '重启中...', + 'ah.restartProcess': '重启进程', + 'ah.gateway': 'Gateway', + 'ah.client': '客户端', + 'ah.worker': 'Worker', + 'ah.running': '运行中', + 'ah.untilAutoRemoval': '{time}后自动移除', + 'ah.removingSoon': '即将移除', + 'ah.last1Hour': '最近 1 小时', + 'ah.last24Hours': '最近 24 小时', + 'ah.last7Days': '最近 7 天', + 'ah.status.healthy': '正常', + 'ah.status.unhealthy': '端口无响应', + 'ah.status.hung': '响应卡住', + 'ah.status.unknown': '待检测', + 'ah.status.noPort': '客户端进程', + 'ah.status.offline': '异常退出', + 'ah.tooltip.healthy': '服务监听端口且 HTTP 探活成功', + 'ah.tooltip.unhealthy': '端口不接受连接,可能需要重启', + 'ah.tooltip.hung': '端口可连但 HTTP 探活超时,进程可能卡死', + 'ah.tooltip.unknown': '首轮健康检查未完成', + 'ah.tooltip.noPort': 'TUI / 子进程,本身不提供服务端口(正常)', + 'ah.tooltip.offline': '进程异常退出,影响了进行中的 LLM 对话,5 分钟后自动移除', + 'ah.tooltip.running': '单进程 agent,本身不提供服务端口,运行正常', + 'ah.requestFailed': '请求失败', + 'ah.failedToLoad': '加载中断事件失败', + 'ah.callLabel': 'call: {id}', + 'ah.copiedValue': '已复制: {value}', + 'ah.copyFailedValue': '复制失败: {value}', + 'ah.markResolvedTitle': '标记为已处理,不再计入未处理统计', + + // ── TokenSavingsPage ── + 'ts.actualConsumption': '实际 Token 消耗', + 'ts.actualConsumptionTip': 'LLM API 实际计费的 Token,已反映 Tokenless 优化效果', + 'ts.savedTokens': '已节省 Token', + 'ts.savedTokensTip': '基线 = 未启用 Tokenless 优化时的 Token 消耗。节省 = 基线 - 实际消耗', + 'ts.baseline': '基线: {n}', + 'ts.savingsRate': '节省率', + 'ts.savingsRateFormula': '= 节省 / 基线 × 100%', + 'ts.excellent': '优秀', + 'ts.good': '良好', + 'ts.needsWork': '待改进', + 'ts.baselineConsumption': '基线消耗', + 'ts.actual': '实际', + 'ts.saved': '节省', + 'ts.noOptimizationRecords': '未找到优化记录', + 'ts.selectTimeRange': '选择时间范围后点击"查询"', + 'ts.viewSavings': '查看 Token 节省效果', + 'ts.optimizationTips': '🎯 优化建议', + 'ts.savingsTop5': '📊 节省 Top 5(按复合节省排序)', + 'ts.category': '类别', + 'ts.savingsStrategy': '节省策略', + 'ts.before': '优化前', + 'ts.optimizedCol': '优化后', + 'ts.savedCol': '节省', + 'ts.detailsCol': '详情', + 'ts.compressionRatio': '压缩率', + 'ts.affectsCalls': '影响后续 {n} 轮调用', + 'ts.compoundedSavings': '复合节省 {n} tokens', + 'ts.singleTurn': '(单轮 {pct}%)', + 'ts.sessionId': 'Session ID', + 'ts.inputTokens': '输入 Tokens', + 'ts.outputTokens': '输出 Tokens', + 'ts.savingsRateCol': '节省率', + 'ts.toolOutput': '工具输出', + 'ts.mcpOutput': 'MCP输出', + 'ts.schemaCompression': 'Schema 压缩', + 'ts.responseCompression': '响应压缩', + 'ts.commandRewrite': '命令重写', + 'ts.toonEncoding': 'TOON 编码', + 'ts.schemaCompressionTip': '精简工具/MCP 接口定义,减少上下文体积', + 'ts.responseCompressionTip': '清理响应冗余字段,保留语义关键内容', + 'ts.commandRewriteTip': '将工具命令重写为更精简的等价形式', + 'ts.toonEncodingTip': '将 JSON 输出转换为紧凑 TOON 表格文本', + 'ts.savedComparedTooltip': '与该会话的基线消耗对比', + 'ts.tool': '工具', + 'ts.mcp': 'MCP', + 'ts.fetchFailed': '获取 Token 节省数据失败', + + // ── AtifViewerPage ── + 'atif.trajectoryViewer': '轨迹查看', + 'atif.downloadJson': '⬇️ 下载 JSON', + 'atif.importJson': '📁 导入 JSON', + 'atif.bySession': '按 Session', + 'atif.byConversation': '按对话', + 'atif.enterSessionId': '输入 Session ID...', + 'atif.enterConversationId': '输入对话 ID...', + 'atif.load': '加载', + 'atif.loading': '加载中...', + 'atif.enterSessionOrConv': '输入 Session 或对话 ID,然后点击"加载"', + 'atif.orImportLocal': '或导入本地 ATIF JSON 文件', + 'atif.agentInfo': 'Agent 信息', + 'atif.name': '名称', + 'atif.version': '版本', + 'atif.model': '模型', + 'atif.toolDefinitions': '工具定义', + 'atif.totalSteps': '总步数', + 'atif.totalInputTokens': '总输入 Tokens', + 'atif.totalOutputTokens': '总输出 Tokens', + 'atif.ofWhichCached': '其中缓存命中: {n}', + 'atif.tokenSavingsComparison': 'Token 节省对比', + 'atif.originalTokens': '原始 Tokens(未优化)', + 'atif.actualTokens': '实际 Tokens(已优化)', + 'atif.interactionTrajectory': '交互轨迹', + 'atif.roundsSteps': '{rounds} 轮 · {steps} 步', + 'atif.noStepData': '该轨迹暂无步骤数据', + 'atif.clickRoundToView': '点击左侧的轮次查看详情', + 'atif.noMessageContent': '无消息内容', + 'atif.reasoning': '推理过程', + 'atif.toolCall': '工具调用', + 'atif.observation': '观测结果', + 'atif.noOutputContent': '无输出内容', + 'atif.inputLabel': '输入: {n}', + 'atif.outputLabel': '输出: {n}', + 'atif.cacheLabel': '缓存: {n}', + 'atif.optimizedTokens': '已优化 -{n} tokens ({strategy})', + 'atif.collapseArgs': '收起参数', + 'atif.expandArgs': '展开参数', + 'atif.subagentTrajectory': '🤖 子代理轨迹', + 'atif.selectSubagentHelp': '在上方拓扑图中选择此子代理以查看其轨迹', + 'atif.round': '第 {n} 轮', + 'atif.preamble': '前置', + 'atif.conversationDetails': '对话详情', + 'atif.toolCalls': '🔧 {n} 次工具调用', + 'atif.system': '系统', + 'atif.user': '用户', + 'atif.agentLabel': 'Agent', + 'atif.jsonParseFailed': 'JSON 解析失败,请检查文件格式', + 'atif.jsonParseFailedNotATIF': 'JSON 解析失败:缺少 schema_version 字段或不是 ATIF 格式', + 'atif.sessionNotFound': '未找到 Session: {id}(无 eBPF 捕获记录且无采集轨迹)', + 'atif.malformedCollected': '采集轨迹格式错误: {id}', + 'atif.cannotResolveSub': '无法解析子轨迹引用:缺少 trajectory_id 或 trajectory_path', + 'atif.externalNotSupported': '暂不支持外部子轨迹引用: {path}', + 'atif.selectSubagentInGraph': '在上方拓扑图中选择此子代理以查看其轨迹', + 'atif.stepLabel': '第 {n} 步', + 'atif.callLabel': 'call: {id}', + 'atif.savedLabel': '节省', + 'atif.originalLabel': '原始', + 'atif.actualLabel': '实际', + 'atif.loadFailed': '加载失败', + + // ── ConversationList ── + 'cl.traceDetails': 'Trace 详情', + 'cl.noDataForTrace': '该 Trace 暂无数据', + 'cl.loadingTraces': '加载 traces...', + 'cl.noTraces': '该 session 暂无 traces', + 'cl.noMessageData': '无消息数据', + 'cl.conversationId': '对话 ID', + 'cl.userQuery': '用户提问', + 'cl.inputTokens': '输入 Tokens', + 'cl.outputTokens': '输出 Tokens', + 'cl.startTime': '开始时间', + 'cl.actions': '操作', + 'cl.qualityEval': '质量评估', + 'cl.interrupts': '中断', + 'cl.eval': '评估', + 'cl.loadFailed': '加载失败', + 'cl.loadFailedTooltip': '评估加载失败,点击重试', + 'cl.sessions': '会话数', + 'cl.totalInputTokens': '总输入 Tokens', + 'cl.totalOutputTokens': '总输出 Tokens', + 'cl.interruptions': '中断', + 'cl.tokenTimeseries': 'Token 时间序列(输入 / 输出 / 总计)', + 'cl.modelTokenTimeseries': '模型 Token 时间序列(堆叠)', + 'cl.noTimeseriesData': '暂无时间序列数据', + 'cl.noModelTimeseriesData': '暂无模型时间序列数据', + 'cl.sessionId': 'Session ID', + 'cl.agent': 'Agent', + 'cl.model': '模型', + 'cl.conversations': '对话数', + 'cl.savedTokens': '已节省 Tokens', + 'cl.lastActive': '最后活跃', + 'cl.noSessions': '所选时间范围内暂无会话', + 'cl.ensureServiceRunning': '请确认 agentsight 服务正在运行并写入数据', + 'cl.unknownModel': '未知模型', + 'cl.queryFailed': '查询失败', + 'cl.loadingEllipsis': '加载中...', + 'cl.totalTokens': '总计 Tokens', + + // ── Security ── + 'sec.securityObservability': '安全可观测', + 'sec.securityObservabilityDesc': 'Security Observability / agent-sec daemon', + 'sec.refresh': '刷新', + 'sec.overview': '概览', + 'sec.securityEvents': '安全事件', + 'sec.fullChainEvents': '全链路事件', + 'sec.currentState': '当前状态为 {state},安全数据视图未加载。', + 'sec.securityEventDetails': '安全事件详情', + 'sec.loadingDetails': '加载详情...', + 'sec.eventNoLongerExists': '该安全事件已不存在。', + 'sec.loadingSecurityStatus': '加载安全观测状态...', + 'sec.statusLoadFailed': '安全观测状态加载失败', + 'sec.refreshStatus': '刷新状态', + 'sec.refreshing': '刷新中...', + 'sec.daemonReachable': 'daemon 可达', + 'sec.disabled': '已禁用', + 'sec.daemonUnreachable': 'daemon 不可达', + 'sec.storeUnavailable': '数据不可用', + 'sec.schemaMismatch': 'schema 不兼容', + 'sec.ok': '正常', + 'sec.empty': '无数据', + 'sec.partial': '部分数据', + 'sec.found': '已找到', + 'sec.notFound': '未找到', + 'sec.apiRequestFailed': '安全观测接口请求失败', + 'sec.sessionOverview': 'Session 总览', + 'sec.sessionOverviewDesc': '按当前时间范围统计所选 session 的安全事件 verdict', + 'sec.currentRun': '当前 Run', + 'sec.observabilityEvents': '观测事件', + 'sec.securityEventsCount': '安全事件', + 'sec.nonPassVerdicts': '存在 {n} 个非 pass verdict', + 'sec.allPass': '全部 pass', + 'sec.loadingTimeline': '加载 timeline...', + 'sec.noTimelineData': '该 run 暂无 timeline 数据。', + 'sec.daemon': 'agent-sec daemon', + 'sec.category': '类别', + 'sec.result': '结果', + 'sec.verdict': 'Verdict', + 'sec.session': 'Session', + 'sec.run': 'Run', + 'sec.call': 'Call', + 'sec.toolCall': '工具调用', + 'sec.trace': 'Trace', + 'sec.turns': 'Turns', + 'sec.eventType': '事件类型', + 'sec.sessionId': 'Session ID', + 'sec.runId': 'Run ID', + 'sec.all': '全部', + 'sec.clear': '清空', + 'sec.redactedByDaemon': '敏感字段已由 agent-sec daemon 脱敏。', + 'sec.truncatedBySize': '部分字段因大小限制被截断。', + 'sec.totalEvents': '共 {n} 条', + 'sec.loadingEvents': '加载安全事件...', + 'sec.noEventsFiltered': '所选过滤条件下暂无安全事件', + 'sec.loadingSummary': '加载安全汇总...', + 'sec.affectedSessions': '影响 Session', + 'sec.affectedRuns': '影响 Run', + 'sec.noEventsInRange': '所选范围内暂无安全事件。', + 'sec.noEventsInRangeShort': '所选范围内暂无安全事件', + 'sec.byCategory': '按类别', + 'sec.byEventType': '按事件类型', + 'sec.byResult': '按结果', + 'sec.noCategoryData': '暂无类别数据', + 'sec.noEventTypeData': '暂无事件类型数据', + 'sec.noResultData': '暂无结果数据', + 'sec.recentSecurityEvents': '近期安全事件', + 'sec.observabilityHook': 'observability {hook}', + 'sec.redacted': '已脱敏', + 'sec.truncated': '已截断', + 'sec.matchReason': '命中 {reason}', + 'sec.viewEventDetailsAria': '查看安全事件详情 {id}', + 'sec.correlatedEvents': '关联安全事件 {n} 条', + 'sec.sessionLabel': 'session {id}', + 'sec.runLabel': 'run {id}', + 'sec.toolLabel': 'tool {id}', + 'sec.metadataMetrics': '元数据 / 指标', + 'sec.selectSessionToAggregate': '选择 Session 后统计 verdict', + 'sec.aggregatingVerdicts': 'Verdict 统计中...', + 'sec.verdictAggregationFailed': 'Verdict 统计失败', + 'sec.noSessionSelected': '未选择 Session', + 'sec.noSecurityEventsShort': '无安全事件', + 'sec.noVerdictsYet': '暂无 verdict', + 'sec.allVerdictsPass': '全部 verdict 为 pass', + 'sec.verdictCoverage': '基于当前时间范围内 {n} 条含 verdict 事件统计', + 'sec.summaryNoEvents': '当前时间范围内未记录安全事件,安全能力没有发现需要展示的检查结果。', + 'sec.summaryChecksPerformed': '安全能力在当前时间范围内执行了 {n} 次检查。', + 'sec.noNonPassFound': '未发现非 pass verdict', + 'sec.summaryAllPass': '安全能力在当前时间范围内执行了 {n} 次检查,已记录 verdict 的事件均为 pass。', + 'sec.riskVerdictsFound': '存在 {n} 个风险 verdict', + 'sec.summaryRiskFound': '安全能力在当前时间范围内执行了 {total} 次检查,其中 {nonPass} 个 verdict 不是 pass,需要关注风险操作。', + 'sec.verdictsToReview': '存在 {n} 个待关注 verdict', + 'sec.summaryReviewRecommended': '安全能力在当前时间范围内执行了 {total} 次检查,其中 {nonPass} 个 verdict 不是 pass,建议复核。', + 'sec.summaryNoVerdictDetails': '安全能力在当前时间范围内执行了 {n} 次检查,但当前样本中暂无 verdict 明细。', + 'sec.capabilityVerdict': '安全能力结论', + 'sec.checksExecuted': '安全能力执行', + 'sec.coveringSessionsRuns': '覆盖 {sessions} Session / {runs} Run', + 'sec.riskyOperationRatio': '风险操作占比', + 'sec.verdictsNotPass': '{nonPass} / {total} 个 verdict 非 pass', + 'sec.needsAttention': '需要关注', + 'sec.riskWarningCount': '风险 {risk} / Warning {warning}', + 'sec.clusteredByVerdict': '按 Verdict 聚类', + 'sec.withVerdict': '{n} 条含 verdict', + 'sec.noVerdictClusterData': '暂无 verdict 聚类数据', + 'sec.whatCapabilityDid': '安全能力做了什么', + 'sec.noCheckActionData': '暂无检查动作数据', + 'sec.executionStatus': '执行状态', + 'sec.noExecutionStatusData': '暂无执行状态数据', + + // ── Components ── + 'comp.eval.pass': '通过', + 'comp.eval.review': '需复核', + 'comp.eval.fail': '未通过', + 'comp.eval.qualityScore': '质量分 {n}', + 'comp.eval.evaluationFailed': '质量评估失败', + 'comp.eval.qualityEvaluation': '质量评估', + 'comp.interrupt.markResolvedConfirm': '标记为已处理后,此中断事件将不再计入未处理统计(badge 数字将减少)。\n\n确认标记为已处理吗?', + 'comp.interrupt.operationFailed': '操作失败,请稍后重试', + 'comp.interrupt.markResolvedTitle': '标记为已处理,不再计入未处理统计', + 'comp.interrupt.unresolvedCount': '{n} 条未处理', + 'comp.interrupt.interruptions': '中断事件', + 'comp.sessionIdHelp.ariaLabel': 'Session ID 用法说明', + 'comp.sessionIdHelp.title': 'Session ID 用法', + 'comp.sessionIdHelp.identifies': '唯一标识一次 Agent 会话。', + 'comp.sessionIdHelp.uses': '用途:', + 'comp.sessionIdHelp.use1': '① 排查问题时在日志里过滤会话', + 'comp.sessionIdHelp.use2': '② 通过 agentsight CLI / API 检索会话详情', + 'comp.sessionIdHelp.use3': '③ 在「🔍 ATIF 查看器」页面粘入 ID 查看完整 trace', + 'comp.sessionIdHelp.copyHint': '点击右侧「复制」后,可在顶部导航栏「🔍 ATIF 查看器」中粘贴查询。', + 'comp.subagentTopology': '🤖 子代理拓扑', + 'comp.subagentCount': '共 {n} 个子代理 · 点击节点在下方查看其轨迹', + 'comp.externalTrajectory': '外部轨迹 ↗', + 'comp.externalTrajectoryDetail': '外部轨迹 {id}', + 'comp.subagentSteps': '{n} 步', + + // ── InterruptionPanel ── + 'comp.interrupt.noEvents': '该会话暂无中断事件记录。', + 'comp.interrupt.failedToLoad': '加载中断事件失败', + 'comp.interrupt.callLabel': 'call: {id}', + 'comp.interrupt.type.llmError': 'LLM 错误', + 'comp.interrupt.type.sseTruncated': 'SSE 截断', + 'comp.interrupt.type.agentCrash': 'Agent 崩溃', + 'comp.interrupt.type.tokenLimit': 'Token 上限', + 'comp.interrupt.type.contextOverflow': '上下文溢出', + + // ── EvaluationPanel ── + 'comp.eval.noResult': '暂无评估结果。', + 'comp.eval.startEval': '开始评估', + 'comp.eval.evaluating': '评估中...', + 'comp.eval.forceEval': '强制评估', + 'comp.eval.pendingCalls': '仍有 {n} 个 LLM 调用待处理。', + 'comp.eval.pendingWhenRan': '评估执行时仍有 {n} 个 LLM 调用待处理。', + 'comp.eval.collapseDetails': '收起详情', + 'comp.eval.viewDetails': '查看详情', + 'comp.eval.dimensions': '评估维度', + 'comp.eval.findings': '发现', + 'comp.eval.noFindings': '无发现。', + 'comp.eval.rootCausePrefix': '根因:', + 'comp.eval.summary.pass': '该对话已完成,未发现确定性的质量问题。', + 'comp.eval.summary.warn': '该对话可用但需要复核:{cause}。', + 'comp.eval.summary.fail': '评估失败,主要原因:{cause}。', + 'comp.eval.action.none': '请复核告警和佐证信息。', + 'comp.eval.action.noActionRequired': '无需额外操作。', + 'comp.eval.action.no_final_answer': '请检查最后一次 LLM 调用和服务端响应解析。', + 'comp.eval.action.interrupted_main_call': '请检查中断证据,修复运行时稳定性后重试会话。', + 'comp.eval.action.agent_crash': '请检查 agent 健康状态和崩溃诊断后再重试。', + 'comp.eval.action.runtime_error': '请检查模型服务错误、网络稳定性和重试行为。', + 'comp.eval.action.tool_failure': '请检查失败的工具调用和工具响应解析。', + 'comp.eval.action.safety_risk': '请复核安全相关发现后再运行 agent。', + 'comp.eval.action.loop_detected': '请检查重复调用并收紧停止条件。', + 'comp.eval.action.excessive_cost': '请审查提示词、工具输出和 Token 节省机会。', + 'comp.eval.action.partial_snapshot': '请等待待处理调用完成,或保持强制评估标记。', + 'comp.eval.cause.none': '未发现明确根因', + 'comp.eval.cause.no_final_answer': '未产生最终回答', + 'comp.eval.cause.interrupted_main_call': '主调用被中断', + 'comp.eval.cause.agent_crash': 'Agent 崩溃', + 'comp.eval.cause.runtime_error': '运行时错误', + 'comp.eval.cause.tool_failure': '工具调用失败', + 'comp.eval.cause.safety_risk': '安全风险', + 'comp.eval.cause.loop_detected': '疑似循环调用', + 'comp.eval.cause.excessive_cost': '成本过高', + 'comp.eval.cause.partial_snapshot': '快照不完整', + 'comp.eval.dim.completion': '完成度', + 'comp.eval.dim.runtime_health': '运行时健康', + 'comp.eval.dim.tool_use': '工具使用', + 'comp.eval.dim.efficiency': '效率', + 'comp.eval.dim.safety': '安全', + 'comp.eval.finding.no_final_answer': '无最终回答', + 'comp.eval.finding.interrupted_main_call': '主调用被中断', + 'comp.eval.finding.partial_snapshot': '快照不完整', + 'comp.eval.finding.tool_failure': '工具失败', + 'comp.eval.finding.loop_detected': '疑似循环', + 'comp.eval.finding.llm_error': 'LLM 错误', + 'comp.eval.finding.sse_truncated': 'SSE 截断', + 'comp.eval.finding.network_timeout': '网络超时', + 'comp.eval.finding.service_unavailable': '服务不可用', + 'comp.eval.finding.agent_crash': 'Agent 崩溃', }, }; +// ─── Context & Provider ─────────────────────────────────────────────────────── + interface I18nContextValue { locale: Locale; setLocale: (locale: Locale) => void; - t: (key: MessageKey) => string; + t: (key: MessageKey, params?: Record) => string; } const I18nContext = createContext(null); @@ -133,6 +1070,11 @@ function syncDocumentMetadata(locale: Locale): void { } } +/** Returns the BCP-47 tag for use with Intl APIs (e.g. toLocaleString). */ +export function localeTag(locale: Locale): string { + return locale; +} + export const I18nProvider: React.FC = ({ children }) => { const [locale, setLocaleState] = useState(resolveInitialLocale); @@ -151,7 +1093,15 @@ export const I18nProvider: React.FC = ({ children }) => }, []); const t = useCallback( - (key: MessageKey) => messages[locale][key], + (key: MessageKey, params?: Record) => { + let msg = messages[locale][key]; + if (params) { + for (const [k, v] of Object.entries(params)) { + msg = msg.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)); + } + } + return msg; + }, [locale], ); @@ -175,6 +1125,14 @@ export function useI18n(): I18nContextValue { return context; } +/** Convenience hook: returns the locale tag for Intl APIs. */ +export function useLocaleTag(): string { + const { locale } = useI18n(); + return localeTag(locale); +} + +// ─── LanguageSwitcher ───────────────────────────────────────────────────────── + interface LanguageSwitcherProps { id: string; className?: string; diff --git a/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx b/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx index c4ec00d0fe..32b1c65f95 100644 --- a/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx +++ b/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx @@ -9,6 +9,8 @@ import { } from '../utils/apiClient'; import type { InterruptionRecord, InterruptionSeverity } from '../utils/apiClient'; import type { AgentHealthStatus } from '../types'; +import { useI18n, useLocaleTag } from '../i18n'; +import type { MessageKey } from '../i18n'; // ─── Agent status section ───────────────────────────────────────────────────── @@ -22,34 +24,34 @@ const STATUS_COLORS: Record = { offline: 'bg-gray-500', }; -/** Status display label */ -const STATUS_LABELS: Record = { - healthy: '正常', - unhealthy: '端口无响应', - hung: '响应卡住', - unknown: '待检测', - no_port: '客户端进程', - offline: '异常退出', +/** Status display label key */ +const STATUS_LABEL_KEY: Record = { + healthy: 'ah.status.healthy', + unhealthy: 'ah.status.unhealthy', + hung: 'ah.status.hung', + unknown: 'ah.status.unknown', + no_port: 'ah.status.noPort', + offline: 'ah.status.offline', }; -/** Status tooltip / 描述,帮助用户理解状态含义 */ -const STATUS_TOOLTIPS: Record = { - healthy: '服务监听端口且 HTTP 探活成功', - unhealthy: '端口不接受连接,可能需要重启', - hung: '端口可连但 HTTP 探活超时,进程可能卡死', - unknown: '首轮健康检查未完成', - no_port: 'TUI / 子进程,本身不提供服务端口(正常)', - offline: '进程异常退出,影响了进行中的 LLM 对话,5 分钟后自动移除', +/** Status tooltip key */ +const STATUS_TOOLTIP_KEY: Record = { + healthy: 'ah.tooltip.healthy', + unhealthy: 'ah.tooltip.unhealthy', + hung: 'ah.tooltip.hung', + unknown: 'ah.tooltip.unknown', + no_port: 'ah.tooltip.noPort', + offline: 'ah.tooltip.offline', }; -/** Format relative time in Chinese */ -function relativeTime(timestampMs: number): string { +/** Format relative time */ +function relativeTime(timestampMs: number, t: (key: MessageKey, params?: Record) => string): string { if (timestampMs === 0) return '—'; const diff = Math.floor((Date.now() - timestampMs) / 1000); - if (diff < 5) return '刚刚'; - if (diff < 60) return `${diff} 秒前`; - if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`; - return `${Math.floor(diff / 3600)} 小时前`; + if (diff < 5) return t('common.justNow'); + if (diff < 60) return t('common.secondsAgo', { n: diff }); + if (diff < 3600) return t('common.minutesAgo', { n: Math.floor(diff / 60) }); + return t('common.hoursAgo', { n: Math.floor(diff / 3600) }); } interface Toast { @@ -64,31 +66,36 @@ const AgentCard: React.FC<{ onRestart: (pid: number) => void; restarting: boolean; }> = ({ agent, related, onDelete, onRestart, restarting }) => { + const { t } = useI18n(); const [showRelated, setShowRelated] = useState(false); - // 区分:真 Gateway = 本身在监听端口的服务进程(如 OpenClaw Gateway) - // 升格 Gateway = 被升格为主卡的单进程 agent(如 Hermes Python CLI)— - // 这种不该贴“Gateway”标签,他们业务上没有 gateway 概念。 + // Real Gateway = a service process that listens on a port itself (e.g. OpenClaw Gateway). + // Promoted Gateway = a single-process agent promoted to a main card (e.g. Hermes Python CLI) — + // these should not carry a "Gateway" label since they have no gateway concept. const hasPorts = (agent.ports?.length ?? 0) > 0; const isRealGateway = agent.role === 'gateway' && hasPorts; const isPromotedGateway = agent.role === 'gateway' && !hasPorts; - // 升格 Gateway + status=no_port 用“运行中”绿色,避免原 no_port 的 - // “客户端进程”灰色语义与主卡身份冲突。 + // Promoted Gateway + status=no_port uses the green "Running" label to avoid + // the gray "client process" semantics conflicting with main-card identity. const useRunningStatus = isPromotedGateway && agent.status === 'no_port'; const dotColor = useRunningStatus ? 'bg-green-500' : STATUS_COLORS[agent.status] || 'bg-gray-400'; - const label = useRunningStatus ? '运行中' : STATUS_LABELS[agent.status] || agent.status; + const labelKey = STATUS_LABEL_KEY[agent.status]; + const label = useRunningStatus + ? t('ah.running') + : labelKey ? t(labelKey) : agent.status; + const tooltipKey = STATUS_TOOLTIP_KEY[agent.status]; const tooltip = useRunningStatus - ? '单进程 agent,本身不提供服务端口,运行正常' - : STATUS_TOOLTIPS[agent.status] || ''; + ? t('ah.tooltip.running') + : tooltipKey ? t(tooltipKey) : ''; const isOffline = agent.status === 'offline'; const isHung = agent.status === 'hung'; const isUnhealthy = agent.status === 'unhealthy'; const canRestart = isHung && !!agent.restart_cmd?.length; - // 计算 offline 项距离自动移除还有多久(5 分钟 TTL) + // Time until offline entries are auto-removed (5-minute TTL) const OFFLINE_TTL_MS = 5 * 60 * 1000; const offlineRemainSec = isOffline && agent.offline_since @@ -115,6 +122,12 @@ const AgentCard: React.FC<{ ? 'text-red-500 font-semibold' : 'text-gray-400'; + const remainText = offlineRemainSec !== null && offlineRemainSec > 0 + ? (offlineRemainSec >= 60 + ? `${Math.ceil(offlineRemainSec / 60)} ${t('common.min')}` + : `${offlineRemainSec} ${t('common.sec')}`) + : null; + return (
@@ -122,17 +135,17 @@ const AgentCard: React.FC<{ {agent.agent_name} {isRealGateway && ( - Gateway + {t('ah.gateway')} )} {agent.role === 'client' && ( - 客户端 + {t('ah.client')} )} {agent.role === 'worker' && ( - Worker + {t('ah.worker')} )} {label} @@ -146,7 +159,7 @@ const AgentCard: React.FC<{ {agent.latency_ms !== null && agent.status === 'healthy' && ( {agent.latency_ms}ms )} - {relativeTime(agent.last_check_time)} + {relativeTime(agent.last_check_time, t)}
{agent.error_message && !isOffline && (
- {offlineRemainSec > 0 - ? `${ - offlineRemainSec >= 60 - ? Math.ceil(offlineRemainSec / 60) + ' 分钟' - : offlineRemainSec + ' 秒' - }后自动移除` - : '即将移除'} + {remainText + ? t('ah.untilAutoRemoval', { time: remainText }) + : t('ah.removingSoon')}
)}
@@ -175,7 +184,7 @@ const AgentCard: React.FC<{ onClick={() => onDelete(agent.pid)} className="text-xs text-gray-400 hover:text-gray-600 underline" > - 立即移除 + {t('ah.removeNow')} )} {canRestart && ( @@ -184,7 +193,7 @@ const AgentCard: React.FC<{ disabled={restarting} className="text-xs text-orange-500 hover:text-orange-700 underline disabled:opacity-50 disabled:cursor-not-allowed" > - {restarting ? '重启中...' : '重启进程'} + {restarting ? t('ah.restarting') : t('ah.restartProcess')} )}
@@ -196,7 +205,7 @@ const AgentCard: React.FC<{ className="text-[11px] text-gray-500 hover:text-gray-700 flex items-center gap-1" > - 关联进程 ({related.length}) + {t('ah.relatedProcesses', { n: related.length })} {showRelated && (
@@ -204,7 +213,7 @@ const AgentCard: React.FC<{
- {ca.role === 'worker' ? 'Worker' : '客户端'} + {ca.role === 'worker' ? t('ah.worker') : t('ah.client')} PID {ca.pid}
@@ -218,6 +227,7 @@ const AgentCard: React.FC<{ }; const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ addToast }) => { + const { t } = useI18n(); const [agents, setAgents] = useState([]); const [clientAgents, setClientAgents] = useState([]); const [showOrphans, setShowOrphans] = useState(false); @@ -229,7 +239,7 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add const refresh = useCallback(async () => { try { - // 一次拉全部(包含 client/worker),后续按 parent_pid 分组挂到各自主卡下面 + // Fetch all at once (including client/worker), then group by parent_pid under each main card const data = await fetchAgentHealth({ includeClients: true }); const agentRows = Array.isArray(data?.agents) ? data.agents : []; setAgents(agentRows.filter(a => a.role === 'gateway')); @@ -239,19 +249,19 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add hasDataRef.current = true; } catch (e: any) { if (!hasDataRef.current) { - setError(e.message || '请求失败'); + setError(e.message || t('ah.requestFailed')); } } finally { setLoading(false); } - }, []); + }, [t]); const handleDelete = async (pid: number) => { try { await deleteAgentHealth(pid); setAgents(prev => prev.filter(a => a.pid !== pid)); } catch (e: any) { - addToast(`删除失败: ${e.message}`); + addToast(t('ah.deleteFailed', { msg: e.message })); } }; @@ -259,11 +269,11 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add setRestartingPids(prev => new Set(prev).add(pid)); try { const result = await restartAgentHealth(pid); - addToast(`✅ 重启成功,新 PID: ${result.new_pid},等待进程上线...`); - // 立即从本地列表删除旧条目,不等下次扫描(新 PID 会在 30s 内自动出现) + addToast(t('ah.restartSucceeded', { pid: result.new_pid })); + // Remove the old entry from the local list immediately (the new PID appears within 30s) setAgents(prev => prev.filter(a => a.pid !== pid)); } catch (e: any) { - addToast(`重启失败: ${e.message}`); + addToast(t('ah.restartFailed', { msg: e.message })); } finally { setRestartingPids(prev => { const next = new Set(prev); @@ -279,7 +289,7 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add return () => clearInterval(timer); }, [refresh]); - // 排序:hung/unhealthy 首位(真有问题),正常中间,offline 最后(不抢眼) + // Sort: hung/unhealthy first (real problems), healthy in the middle, offline last (less prominent) const sorted = [...agents].sort((a, b) => { const order: Record = { hung: 0, @@ -298,8 +308,8 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add const totalCount = agents.length; const gatewayPids = new Set(sorted.map(a => a.pid)); - // 孤儿关联进程:Worker 但父进程不是任何主卡(不应出现,兜底)。 - // 过滤 status=offline 的进程——它们 5 分钟后会被 TTL 自动清理。 + // Orphaned related processes: workers whose parent is not any main card (should not happen, fallback). + // Filter out status=offline processes — they are TTL-cleaned after 5 minutes. const orphans = clientAgents.filter( c => c.status !== 'offline' && @@ -312,38 +322,38 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add

- Agent 看板 + {t('ah.agentDashboard')}

{offlineCount > 0 && ( - {offlineCount} 崩溃 + {t('ah.crashed', { n: offlineCount })} )} {hungCount > 0 && ( - {hungCount} 卡顿 + {t('ah.hungCount', { n: hungCount })} )} {totalCount > 0 && ( - {healthyCount}/{totalCount} 正常 + {t('ah.healthyRatio', { healthy: healthyCount, total: totalCount })} )}
{lastScan > 0 && ( - 上次扫描: {relativeTime(lastScan)} + {t('ah.lastScan', { time: relativeTime(lastScan, t) })} )}
{loading ? ( -
加载中...
+
{t('common.loading')}
) : error ? (
{error}
) : sorted.length === 0 ? (
- 暂无已发现的 Agent + {t('ah.noAgents')}
) : (
@@ -351,8 +361,8 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add c.parent_pid === agent.pid)} onDelete={handleDelete} onRestart={handleRestart} @@ -369,7 +379,7 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1" > - 孤儿关联进程 ({orphans.length}) + {t('ah.orphanedProcesses', { n: orphans.length })} {showOrphans && (
@@ -378,7 +388,7 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add {ca.agent_name} - {ca.role === 'worker' ? 'Worker' : '客户端'} + {ca.role === 'worker' ? t('ah.worker') : t('ah.client')} PID {ca.pid}
@@ -400,25 +410,25 @@ const SEVERITY_DOT: Record = { low: 'bg-blue-400', }; -const SEVERITY_LABELS: Record = { - critical: '致命', - high: '严重', - medium: '中等', - low: '轻微', +const SEVERITY_LABEL_KEY: Record = { + critical: 'common.critical', + high: 'common.high', + medium: 'common.medium', + low: 'common.low', }; -const TIME_RANGES: { label: string; hours: number }[] = [ - { label: '最近 1 小时', hours: 1 }, - { label: '最近 24 小时', hours: 24 }, - { label: '最近 7 天', hours: 24 * 7 }, +const TIME_RANGE_KEYS: { labelKey: MessageKey; hours: number }[] = [ + { labelKey: 'ah.last1Hour', hours: 1 }, + { labelKey: 'ah.last24Hours', hours: 24 }, + { labelKey: 'ah.last7Days', hours: 24 * 7 }, ]; -function formatNs(ns: number): string { - return new Date(ns / 1_000_000).toLocaleString(); +function formatNs(ns: number, locale: string): string { + return new Date(ns / 1_000_000).toLocaleString(locale); } -function parseDetail(raw: string | null): React.ReactNode { - if (!raw) return 无详情; +function parseDetail(raw: string | null, t: (key: MessageKey) => string): React.ReactNode { + if (!raw) return {t('ah.noDetails')}; try { const obj = JSON.parse(raw); return ( @@ -436,13 +446,14 @@ const IDWithCopy: React.FC<{ value: string | null; addToast: (msg: string) => vo value, addToast, }) => { + const { t } = useI18n(); if (!value) return ; const short = value.length > 8 ? `${value.slice(0, 8)}…` : value; const copy = async (e: React.MouseEvent) => { e.stopPropagation(); let ok = false; try { - // 优先 clipboard API(HTTPS / localhost) + // Prefer the clipboard API (HTTPS / localhost) if (window.isSecureContext && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); ok = true; @@ -451,7 +462,7 @@ const IDWithCopy: React.FC<{ value: string | null; addToast: (msg: string) => vo // fall through } if (!ok) { - // 降级:临时 textarea + execCommand,HTTP 环境也能用 + // Fallback: temporary textarea + execCommand, works over HTTP too try { const ta = document.createElement('textarea'); ta.value = value; @@ -467,7 +478,7 @@ const IDWithCopy: React.FC<{ value: string | null; addToast: (msg: string) => vo ok = false; } } - addToast(ok ? `已复制: ${value}` : `复制失败: ${value}`); + addToast(ok ? t('ah.copiedValue', { value }) : t('ah.copyFailedValue', { value })); }; return ( @@ -475,9 +486,9 @@ const IDWithCopy: React.FC<{ value: string | null; addToast: (msg: string) => vo )}
@@ -573,9 +585,9 @@ const InterruptionEventRow: React.FC<{ {event.call_id && ( -
call: {event.call_id}
+
{t('ah.callLabel', { id: event.call_id })}
)} - {parseDetail(event.detail)} + {parseDetail(event.detail, t)} )} @@ -586,6 +598,7 @@ const InterruptionEventRow: React.FC<{ const PAGE_SIZES = [15, 30, 50]; const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ addToast }) => { + const { t } = useI18n(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -614,12 +627,12 @@ const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ ad hasDataRef.current = true; } catch (e: any) { if (!hasDataRef.current) { - setError(e.message ?? '加载中断事件失败'); + setError(e.message ?? t('ah.failedToLoad')); } } finally { setLoading(false); } - }, [hours, typeFilter, severityFilter, unresolvedOnly]); + }, [hours, typeFilter, severityFilter, unresolvedOnly, t]); useEffect(() => { setLoading(true); @@ -628,7 +641,7 @@ const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ ad return () => clearInterval(timer); }, [load]); - // 过滤条件变化时重置到第 1 页,避免筛选后停留在空页 + // Reset to page 1 when filters change to avoid staying on an empty page useEffect(() => { setPage(1); }, [hours, typeFilter, severityFilter, unresolvedOnly]); @@ -652,24 +665,24 @@ const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ ad
-

中断事件

+

{t('ah.interruptionEvents')}

{!loading && ( - {unresolvedCount} 条未处理 + {t('ah.unresolvedCount', { n: unresolvedCount })} )}
@@ -678,10 +691,10 @@ const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ ad onChange={e => setSeverityFilter(e.target.value)} className={selectClass} > - - {(Object.keys(SEVERITY_LABELS) as InterruptionSeverity[]).map(s => ( + + {(Object.keys(SEVERITY_LABEL_KEY) as InterruptionSeverity[]).map(s => ( ))} @@ -691,31 +704,31 @@ const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ ad checked={unresolvedOnly} onChange={e => setUnresolvedOnly(e.target.checked)} /> - 仅未处理 + {t('common.unresolvedOnly')}
{loading ? ( -
加载中...
+
{t('common.loading')}
) : error ? (
{error}
) : events.length === 0 ? ( -
当前筛选条件下暂无中断事件
+
{t('ah.noInterruptionEvents')}
) : ( <> - - - - - - - - + + + + + + + + @@ -739,14 +752,14 @@ const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ ad return (
- 共 {events.length} 条 + {t('common.events', { n: events.length })} · - 第 {clamped}/{totalPages} 页 + {t('common.page', { cur: clamped, total: totalPages })} ·
@@ -770,28 +782,28 @@ const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ ad disabled={clamped <= 1} className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-gray-50" > - 首页 + {t('common.first')}
diff --git a/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx b/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx index 94dddb8293..03020c8b64 100644 --- a/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx +++ b/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx @@ -14,6 +14,8 @@ import type { TrajNode } from '../utils/trajectoryTree'; import { buildTrajectoryTree, findNodeByPath, findNodeByRef, encodeNodePath, decodeNodePath, } from '../utils/trajectoryTree'; +import { useI18n, useLocaleTag } from '../i18n'; +import type { MessageKey } from '../i18n'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -21,10 +23,10 @@ function fmtTokens(n: number): string { return n.toLocaleString(); } -function fmtTimestamp(iso?: string): string { +function fmtTimestamp(iso: string | undefined, locale: string): string { if (!iso) return ''; try { - return new Date(iso).toLocaleString('zh-CN', { + return new Date(iso).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); @@ -86,11 +88,14 @@ function highlightedSections(doc: AtifDocument, callId: string | null): Set) => string): Round[] { const rounds: Round[] = []; let userRoundCount = 0; for (const step of steps) { @@ -99,7 +104,8 @@ function groupIntoRounds(steps: AtifStep[]): Round[] { if (isUser) userRoundCount++; rounds.push({ key: rounds.length, - label: isUser ? `第 ${userRoundCount} 轮` : '前置', + label: isUser ? t('atif.round', { n: userRoundCount }) : t('atif.preamble'), + isPreamble: !isUser, userStep: isUser ? step : null, steps: [step], }); @@ -125,42 +131,51 @@ function initialRound(rounds: Round[], sections: Set): number | null { // ─── Strategy label config (shared with TokenSavingsPage) ──────────────────── -const STRATEGY_LABELS: Record = { - 'compress-schema': { label: 'Schema 压缩', color: 'text-blue-700', bg: 'bg-blue-100' }, - 'compress-response': { label: '响应压缩', color: 'text-violet-700', bg: 'bg-violet-100' }, - 'rewrite-command': { label: '命令重写', color: 'text-orange-700', bg: 'bg-orange-100' }, - 'compress-toon': { label: 'TOON 编码', color: 'text-teal-700', bg: 'bg-teal-100' }, +const STRATEGY_STYLES: Record = { + 'compress-schema': { color: 'text-blue-700', bg: 'bg-blue-100' }, + 'compress-response': { color: 'text-violet-700', bg: 'bg-violet-100' }, + 'rewrite-command': { color: 'text-orange-700', bg: 'bg-orange-100' }, + 'compress-toon': { color: 'text-teal-700', bg: 'bg-teal-100' }, +}; + +const STRATEGY_LABEL_KEYS: Record = { + 'compress-schema': 'ts.schemaCompression', + 'compress-response': 'ts.responseCompression', + 'rewrite-command': 'ts.commandRewrite', + 'compress-toon': 'ts.toonEncoding', }; // ─── Source styling ─────────────────────────────────────────────────────────── -const SOURCE_STYLES: Record = { +const SOURCE_STYLES: Record = { system: { dot: 'bg-purple-500', badge: 'bg-purple-100 text-purple-700', border: 'border-l-purple-400', - label: '系统', }, user: { dot: 'bg-blue-500', badge: 'bg-blue-100 text-blue-700', border: 'border-l-blue-400', - label: '用户', }, agent: { dot: 'bg-green-500', badge: 'bg-green-100 text-green-700', border: 'border-l-green-400', - label: 'Agent', }, }; +const SOURCE_LABEL_KEYS: Record = { + system: 'atif.system', + user: 'atif.user', + agent: 'atif.agentLabel', +}; + function getSourceStyle(source: string) { return SOURCE_STYLES[source] ?? { dot: 'bg-gray-400', badge: 'bg-gray-100 text-gray-600', border: 'border-l-gray-300', - label: source, }; } @@ -199,6 +214,7 @@ const Collapsible: React.FC = ({ icon, title, count, isOpen, o const TEXT_THRESHOLD = 300; const ExpandableText: React.FC<{ text: string; className?: string }> = ({ text, className = '' }) => { + const { t } = useI18n(); const [expanded, setExpanded] = useState(false); const isLong = text.length > TEXT_THRESHOLD; const display = isLong && !expanded ? text.slice(0, TEXT_THRESHOLD) + '\u2026' : text; @@ -213,7 +229,7 @@ const ExpandableText: React.FC<{ text: string; className?: string }> = ({ text, onClick={() => setExpanded(!expanded)} className="mt-1 text-xs text-blue-600 hover:text-blue-800" > - {expanded ? '← 收起' : '展开全部 →'} + {expanded ? t('common.collapseAll') : t('common.expandAll')} )} @@ -231,7 +247,10 @@ interface StepCardProps { } const StepCard: React.FC = ({ step, expandedSections, onToggleSection, savingsMap, onNavigateSubagent }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); const style = getSourceStyle(step.source); + const sourceLabel = SOURCE_LABEL_KEYS[step.source] ? t(SOURCE_LABEL_KEYS[step.source]) : step.source; const sectionKey = (name: string) => `${step.step_id}-${name}`; const isOpen = (name: string) => expandedSections.has(sectionKey(name)); const toggle = (name: string) => onToggleSection(sectionKey(name)); @@ -256,11 +275,11 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec {/* Header */}
- {style.label} + {sourceLabel} - Step {step.step_id} + {t('atif.stepLabel', { n: step.step_id })} {step.timestamp && ( - {fmtTimestamp(step.timestamp)} + {fmtTimestamp(step.timestamp, locale)} )} {step.model_name && ( @@ -275,7 +294,7 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec {step.message ? ( ) : ( - 无消息内容 + {t('atif.noMessageContent')} )} {/* Agent-only sections */} @@ -285,7 +304,7 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec {hasReasoning && ( toggle('reasoning')} > @@ -299,7 +318,7 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec {hasToolCalls && ( toggle('toolcalls')} @@ -316,7 +335,7 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec {hasObservation && ( toggle('observation')} @@ -330,7 +349,7 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec
{r.source_call_id && (
- call: {shortId(r.source_call_id, 16)} + {t('atif.callLabel', { id: shortId(r.source_call_id, 16) })}
)} {hasSubagentRef && ( @@ -340,9 +359,9 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec key={ri} onClick={() => onNavigateSubagent?.(ref)} className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-100 hover:bg-indigo-200 text-indigo-700 rounded-lg text-xs font-medium transition-colors" - title="在上方拓扑图中选中该子代理并查看其轨迹" + title={t('atif.selectSubagentInGraph')} > - 🤖 子代理轨迹 + {t('atif.subagentTrajectory')} {ref.trajectory_id && ( {shortId(ref.trajectory_id, 12)} )} @@ -355,7 +374,7 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec
) : !hasSubagentRef ? ( -
无输出内容
+
{t('atif.noOutputContent')}
) : null}
); @@ -369,17 +388,17 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec
{step.metrics!.prompt_tokens != null && ( - 输入: {fmtTokens(step.metrics!.prompt_tokens!)} + {t('atif.inputLabel', { n: fmtTokens(step.metrics!.prompt_tokens!) })} )} {step.metrics!.completion_tokens != null && ( - 输出: {fmtTokens(step.metrics!.completion_tokens!)} + {t('atif.outputLabel', { n: fmtTokens(step.metrics!.completion_tokens!) })} )} {step.metrics!.cached_tokens != null && step.metrics!.cached_tokens! > 0 && ( - 缓存: {fmtTokens(step.metrics!.cached_tokens!)} + {t('atif.cacheLabel', { n: fmtTokens(step.metrics!.cached_tokens!) })} )}
@@ -395,13 +414,15 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec // ─── ToolCallItem ───────────────────────────────────────────────────────────── const ToolCallItem: React.FC<{ tc: AtifToolCall; savingsMap?: Map }> = ({ tc, savingsMap }) => { + const { t } = useI18n(); const [showArgs, setShowArgs] = useState(false); const argsStr = typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments, null, 2); const isLongArgs = argsStr.length > 200; const savings = savingsMap?.get(tc.tool_call_id); - const stratStyle = savings ? (STRATEGY_LABELS[savings.strategy] ?? { label: savings.strategy_label, color: 'text-gray-700', bg: 'bg-gray-100' }) : null; + const stratStyle = savings ? (STRATEGY_STYLES[savings.strategy] ?? { color: 'text-gray-700', bg: 'bg-gray-100' }) : null; + const stratLabelKey = savings ? STRATEGY_LABEL_KEYS[savings.strategy] : undefined; return (
@@ -412,7 +433,10 @@ const ToolCallItem: React.FC<{ tc: AtifToolCall; savingsMap?: Map{shortId(tc.tool_call_id, 16)} {savings && stratStyle && ( - 已优化 -{fmtTokens(savings.compounded_saved)} tokens ({stratStyle.label}) + {t('atif.optimizedTokens', { + n: fmtTokens(savings.compounded_saved), + strategy: stratLabelKey ? t(stratLabelKey) : savings.strategy_label, + })} )} {isLongArgs && ( @@ -420,7 +444,7 @@ const ToolCallItem: React.FC<{ tc: AtifToolCall; savingsMap?: Map setShowArgs(!showArgs)} className="ml-auto text-xs text-blue-600 hover:text-blue-800" > - {showArgs ? '收起参数' : '展开参数'} + {showArgs ? t('atif.collapseArgs') : t('atif.expandArgs')} )}
@@ -463,6 +487,8 @@ interface RoundListItemProps { } const RoundListItem: React.FC = ({ round, isActive, onSelect }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); const stats = roundStats(round); return ( @@ -480,22 +506,22 @@ const RoundListItem: React.FC = ({ round, isActive, onSelect }`}> {round.label} - {stats.firstTs && {fmtTimestamp(stats.firstTs)}} + {stats.firstTs && {fmtTimestamp(stats.firstTs, locale)}}

- {stats.preview || 无消息内容} + {stats.preview || {t('atif.noMessageContent')}}

- {round.steps.length} 步 + {t('common.steps', { n: round.steps.length })} {stats.toolCallCount > 0 && ( - 🔧 {stats.toolCallCount} + {'\ud83d\udd27'} {stats.toolCallCount} )} {(stats.promptSum > 0 || stats.completionSum > 0) && ( - {fmtTokens(stats.promptSum)} in / {fmtTokens(stats.completionSum)} out + {t('common.inOut', { in: fmtTokens(stats.promptSum), out: fmtTokens(stats.completionSum) })} )}
@@ -516,20 +542,21 @@ interface RoundDetailProps { const RoundDetail: React.FC = ({ round, expandedSections, onToggleSection, savingsMap, onNavigateSubagent, }) => { + const { t } = useI18n(); const stats = roundStats(round); return (
{/* Detail header */}
-

{round.label} · 对话详情

- {round.steps.length} 步 +

{round.label} · {t('atif.conversationDetails')}

+ {t('common.steps', { n: round.steps.length })} {stats.toolCallCount > 0 && ( - 🔧 {stats.toolCallCount} 次工具调用 + {t('atif.toolCalls', { n: stats.toolCallCount })} )} {(stats.promptSum > 0 || stats.completionSum > 0) && ( - {fmtTokens(stats.promptSum)} in / {fmtTokens(stats.completionSum)} out + {t('common.inOut', { in: fmtTokens(stats.promptSum), out: fmtTokens(stats.completionSum) })} )}
@@ -555,18 +582,19 @@ const RoundDetail: React.FC = ({ // ─── AgentInfoCard ──────────────────────────────────────────────────────────── const AgentInfoCard: React.FC<{ doc: AtifDocument }> = ({ doc }) => { + const { t } = useI18n(); const agent = doc.agent ?? { name: 'unknown', version: '—', model_name: undefined, tool_definitions: [] }; const toolCount = Array.isArray(agent.tool_definitions) ? agent.tool_definitions.length : 0; return (
-

Agent 信息

+

{t('atif.agentInfo')}

{[ - { label: '名称', value: agent.name }, - { label: '版本', value: agent.version }, - { label: '模型', value: agent.model_name ?? '—' }, - { label: '工具定义', value: `${toolCount} 个` }, + { label: t('atif.name'), value: agent.name }, + { label: t('atif.version'), value: agent.version }, + { label: t('atif.model'), value: agent.model_name ?? '—' }, + { label: t('atif.toolDefinitions'), value: `${toolCount}` }, ].map(({ label, value }) => (
{label} @@ -587,7 +615,6 @@ const MetricCard: React.FC<{ label: string; value: string; color: string; sub?: {sub && {sub}}
); - // ─── Session loading (two stores) ───────────────────────────────────────────── // A session lives in either store: eBPF-captured genai events (genai_events.db) // or a collector-ingested log trajectory (trajectories.db). Both now serve the @@ -602,7 +629,10 @@ function isAtifDocument(value: unknown): value is AtifDocument { && String((value as { schema_version: string }).schema_version).startsWith('ATIF'); } -async function loadSessionDoc(sessionId: string): Promise { +async function loadSessionDoc( + sessionId: string, + t: (key: MessageKey, params?: Record) => string, +): Promise { try { const exported = await fetchAtifBySession(sessionId); if (isAtifDocument(exported)) return exported; @@ -613,10 +643,10 @@ async function loadSessionDoc(sessionId: string): Promise { try { const collected = await fetchTrajectoryAtif(sessionId); if (isAtifDocument(collected)) return collected; - throw new Error(`采集轨迹格式异常:${sessionId}`); + throw new Error(t('atif.malformedCollected', { id: sessionId })); } catch (fallbackErr: any) { if (fallbackErr?.status === 404) { - throw new Error(`未找到该 Session:${sessionId}(既无 eBPF 捕获记录,也无采集轨迹)`); + throw new Error(t('atif.sessionNotFound', { id: sessionId })); } throw fallbackErr; } @@ -625,6 +655,7 @@ async function loadSessionDoc(sessionId: string): Promise { // ─── Main Page ──────────────────────────────────────────────────────────────── export const AtifViewerPage: React.FC = () => { + const { t } = useI18n(); const [searchParams, setSearchParams] = useSearchParams(); const searchParamsRef = useRef(searchParams); @@ -673,14 +704,14 @@ export const AtifViewerPage: React.FC = () => { } setNodePath(node.path); setExpandedSections(new Set()); - setSelectedRound(initialRound(groupIntoRounds(stepsOf(node.doc)), new Set())); + setSelectedRound(initialRound(groupIntoRounds(stepsOf(node.doc), t), new Set())); const next = new URLSearchParams(searchParamsRef.current); if (node.path.length > 0) next.set('node', encodeNodePath(node.path)); else next.delete('node'); setSearchParams(next); - }, [setSearchParams]); + }, [setSearchParams, t]); - /** Step-level "🤖 子代理轨迹" button: select the node in the graph above. */ + /** Step-level "🤖 Subagent trajectory" button: select the node in the graph above. */ const navigateToSubagent = useCallback((ref: SubagentTrajectoryRef) => { const target = tree ? findNodeByRef(tree, ref) : null; if (target) { @@ -691,11 +722,11 @@ export const AtifViewerPage: React.FC = () => { if (ref.session_id) { window.open(`#/atif?type=session&id=${encodeURIComponent(ref.session_id)}`, '_blank'); } else if (ref.trajectory_path) { - setError(`外部子轨迹引用暂不支持: ${ref.trajectory_path}`); + setError(t('atif.externalNotSupported', { path: ref.trajectory_path })); } else { - setError('无法解析子轨迹引用:缺少 trajectory_id 或 trajectory_path'); + setError(t('atif.cannotResolveSub')); } - }, [tree, selectNode]); + }, [tree, selectNode, t]); const toggleSection = useCallback((key: string) => { setExpandedSections(prev => { @@ -708,11 +739,11 @@ export const AtifViewerPage: React.FC = () => { // Load data const handleLoad = useCallback(async (type?: 'session' | 'conversation', id?: string) => { - const t = type ?? queryType; + const qt = type ?? queryType; const i = id ?? queryId; if (!i.trim()) return; - const nextParams: Record = { type: t, id: i.trim() }; + const nextParams: Record = { type: qt, id: i.trim() }; const currentSearchParams = searchParamsRef.current; // Node selection and highlights only carry over when the target is unchanged // (an explicit reload of a different id starts at the root trajectory). @@ -738,10 +769,10 @@ export const AtifViewerPage: React.FC = () => { try { let data: AtifDocument; - if (t === 'conversation') { + if (qt === 'conversation') { data = await fetchAtifByConversation(i.trim()); } else { - data = await loadSessionDoc(i.trim()); + data = await loadSessionDoc(i.trim(), t); } setDoc(data); const sections = highlightedSections(data, nextParams.highlight_call_id ?? null); @@ -751,7 +782,7 @@ export const AtifViewerPage: React.FC = () => { const restoredDoc = restoredTree ? (findNodeByPath(restoredTree, initialPath).doc ?? data) : data; - setSelectedRound(initialRound(groupIntoRounds(stepsOf(restoredDoc)), sections)); + setSelectedRound(initialRound(groupIntoRounds(stepsOf(restoredDoc), t), sections)); // Fetch savings data for the session if (data.session_id) { fetchSessionSavings(data.session_id) @@ -759,11 +790,11 @@ export const AtifViewerPage: React.FC = () => { .catch(() => setSavingsDetail(null)); } } catch (e: any) { - setError(e.message ?? '加载失败'); + setError(e.message ?? t('atif.loadFailed')); } finally { setLoading(false); } - }, [queryType, queryId, setSearchParams]); + }, [queryType, queryId, setSearchParams, t]); // Back/forward navigation changes the URL without going through selectNode, // so mirror the `node` param back into state when they diverge. @@ -773,8 +804,8 @@ export const AtifViewerPage: React.FC = () => { if (encodeNodePath(urlPath) === encodeNodePath(nodePath)) return; setNodePath(urlPath); setExpandedSections(new Set()); - setSelectedRound(initialRound(groupIntoRounds(stepsOf(findNodeByPath(tree, urlPath).doc)), new Set())); - }, [searchParams, tree, nodePath]); + setSelectedRound(initialRound(groupIntoRounds(stepsOf(findNodeByPath(tree, urlPath).doc), t), new Set())); + }, [searchParams, tree, nodePath, t]); // Auto-load from URL on mount useEffect(() => { @@ -797,7 +828,7 @@ export const AtifViewerPage: React.FC = () => { try { const parsed = JSON.parse(ev.target?.result as string); if (!parsed.schema_version || !String(parsed.schema_version).startsWith('ATIF')) { - setError('JSON 解析失败:缺少 schema_version 字段或非 ATIF 格式'); + setError(t('atif.jsonParseFailedNotATIF')); return; } setDoc(parsed as AtifDocument); @@ -805,14 +836,14 @@ export const AtifViewerPage: React.FC = () => { setError(null); setQueryId(parsed.session_id ?? ''); setExpandedSections(new Set()); - setSelectedRound(initialRound(groupIntoRounds(stepsOf(parsed as AtifDocument)), new Set())); + setSelectedRound(initialRound(groupIntoRounds(stepsOf(parsed as AtifDocument), t), new Set())); } catch { - setError('JSON 解析失败,请检查文件格式'); + setError(t('atif.jsonParseFailed')); } }; reader.readAsText(file); e.target.value = ''; - }, []); + }, [t]); // JSON download const handleDownload = useCallback(() => { @@ -828,7 +859,7 @@ export const AtifViewerPage: React.FC = () => { // Compute metrics (fallback when final_metrics is partial) const steps = activeDoc?.steps ?? []; - const rounds = React.useMemo(() => groupIntoRounds(activeDoc?.steps ?? []), [activeDoc]); + const rounds = React.useMemo(() => groupIntoRounds(activeDoc?.steps ?? [], t), [activeDoc, t]); const activeRound = rounds.find(r => r.key === selectedRound) ?? null; const computedMetrics = activeDoc ? (() => { const fm = activeDoc.final_metrics; @@ -854,7 +885,7 @@ export const AtifViewerPage: React.FC = () => {
-

轨迹查看

+

{t('atif.trajectoryViewer')}

{doc && (
@@ -867,7 +898,7 @@ export const AtifViewerPage: React.FC = () => { {doc && ( )}
@@ -878,17 +909,17 @@ export const AtifViewerPage: React.FC = () => {
{/* Type toggle */}
- {(['session', 'conversation'] as const).map(t => ( + {(['session', 'conversation'] as const).map(mode => ( ))}
@@ -900,7 +931,7 @@ export const AtifViewerPage: React.FC = () => { value={queryId} onChange={e => setQueryId(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleLoad(); }} - placeholder={queryType === 'conversation' ? '输入 Conversation ID...' : '输入 Session ID...'} + placeholder={queryType === 'conversation' ? t('atif.enterConversationId') : t('atif.enterSessionId')} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-400" />
@@ -911,7 +942,7 @@ export const AtifViewerPage: React.FC = () => { disabled={loading || !queryId.trim()} className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" > - {loading ? '加载中...' : '加载'} + {loading ? t('atif.loading') : t('atif.load')} {/* File import */} @@ -926,14 +957,14 @@ export const AtifViewerPage: React.FC = () => { onClick={() => fileInputRef.current?.click()} className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm rounded-lg transition-colors" > - 📁 导入 JSON + {t('atif.importJson')}
{/* Error */} {error && (
- ⚠️ {error} + {'\u26a0\ufe0f'} {error}
)} @@ -942,7 +973,7 @@ export const AtifViewerPage: React.FC = () => {
-

加载中...

+

{t('atif.loading')}

)} @@ -952,8 +983,8 @@ export const AtifViewerPage: React.FC = () => {

ATIF

-

请输入 Session 或 Conversation ID,然后点击「加载」

-

或导入本地 ATIF JSON 文件

+

{t('atif.enterSessionOrConv')}

+

{t('atif.orImportLocal')}

)} @@ -967,18 +998,18 @@ export const AtifViewerPage: React.FC = () => { {computedMetrics && ( <> 0 ? `其中缓存: ${fmtTokens(computedMetrics.cached)}` : undefined} + sub={computedMetrics.cached > 0 ? t('atif.ofWhichCached', { n: fmtTokens(computedMetrics.cached) }) : undefined} /> @@ -989,18 +1020,18 @@ export const AtifViewerPage: React.FC = () => { {/* Token Savings Comparison Card */} {savingsDetail && savingsDetail.total_compounded_saved > 0 && (
-

Token 节省对比

+

{t('atif.tokenSavingsComparison')}

- 原始 Token(未优化) + {t('atif.originalTokens')}

{fmtTokens(savingsDetail.total_original_tokens)}

- 实际 Token(优化后) + {t('atif.actualTokens')}

{fmtTokens(savingsDetail.total_actual_tokens)}

- 节省 + {t('atif.savedLabel')}

-{fmtTokens(savingsDetail.total_compounded_saved)} @@ -1012,13 +1043,13 @@ export const AtifViewerPage: React.FC = () => { {/* Comparison bar */}

- 原始 + {t('atif.originalLabel')}
- 实际 + {t('atif.actualLabel')}
{ {selectedNode && selectedNode.depth > 0 && ( {selectedNode.label} · )} - 交互轨迹 + {t('atif.interactionTrajectory')} - 共 {rounds.length} 轮对话 · {steps.length} 步 + {t('atif.roundsSteps', { rounds: rounds.length, steps: steps.length })} {steps.length === 0 ? (

--

-

该轨迹暂无步骤数据

+

{t('atif.noStepData')}

) : (
@@ -1084,7 +1115,7 @@ export const AtifViewerPage: React.FC = () => { /> ) : (
-

点击左侧轮次查看详情

+

{t('atif.clickRoundToView')}

)}
@@ -1095,6 +1126,7 @@ export const AtifViewerPage: React.FC = () => { sessionId={queryId} roundIndex={selectedRound ?? undefined} roundLabel={activeRound?.label} + isPreambleRound={activeRound?.isPreamble ?? false} idKind={queryType} />
diff --git a/src/agentsight/dashboard/src/pages/ConversationList.tsx b/src/agentsight/dashboard/src/pages/ConversationList.tsx index 7636ceeee3..6add0fdee9 100644 --- a/src/agentsight/dashboard/src/pages/ConversationList.tsx +++ b/src/agentsight/dashboard/src/pages/ConversationList.tsx @@ -10,6 +10,8 @@ import { EvaluationBadge } from '../components/EvaluationBadge'; import { EvaluationPanel } from '../components/EvaluationPanel'; import { DateTimePicker } from '../components/DateTimePicker'; import { SessionIdHelp } from '../components/SessionIdHelp'; +import { useI18n, useLocaleTag } from '../i18n'; +import type { MessageKey } from '../i18n'; import { fetchSessions, fetchTraces, @@ -32,14 +34,13 @@ import { SessionInterruptionCount, ConversationInterruptionCount, EvaluationResult, - INTERRUPTION_TYPE_CN, } from '../utils/apiClient'; // ─── Helpers ────────────────────────────────────────────────────────────────── /** Convert nanoseconds to a display string */ -function nsToDate(ns: number): string { - return new Date(ns / 1_000_000).toLocaleString('zh-CN', { +function nsToDate(ns: number, locale: string): string { + return new Date(ns / 1_000_000).toLocaleString(locale, { year: 'numeric', month: '2-digit', day: '2-digit', @@ -54,8 +55,9 @@ function shortId(id: string, len = 16): string { return id.length > len ? id.slice(0, len) + '…' : id; } -/** 复制按钮组件,点击后短暂显示「已复制」反馈 */ +/** Copy button with a brief "Copied" feedback */ const CopyButton: React.FC<{ text: string }> = ({ text }) => { + const { t } = useI18n(); const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); const handleCopy = (e: React.MouseEvent) => { @@ -65,7 +67,7 @@ const CopyButton: React.FC<{ text: string }> = ({ text }) => { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); }; - // HTTP 环境下 clipboard API 可能不可用,使用 execCommand fallback + // Clipboard API may be unavailable over HTTP; fall back to execCommand if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done)); } else { @@ -80,9 +82,9 @@ const CopyButton: React.FC<{ text: string }> = ({ text }) => { ? 'bg-green-100 text-green-600' : 'bg-gray-100 hover:bg-gray-200 text-gray-500 hover:text-gray-700' }`} - title="复制完整 ID" + title={t('common.copyFullId')} > - {copied ? '✓ 已复制' : '复制'} + {copied ? t('common.copied') : t('common.copy')} ); }; @@ -113,6 +115,8 @@ interface TraceDetailModalProps { } const TraceDetailModal: React.FC = ({ traceId, onClose }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -201,7 +205,7 @@ const TraceDetailModal: React.FC = ({ traceId, onClose }) {/* Header */}
-

Trace 详情

+

{t('cl.traceDetails')}

{traceId}

- 输入 {fmtTokens(ev.input_tokens)} + {t('common.input')} {fmtTokens(ev.input_tokens)} - 输出 {fmtTokens(ev.output_tokens)} + {t('common.output')} {fmtTokens(ev.output_tokens)} - 总计 {fmtTokens(ev.total_tokens)} + {t('common.total')} {fmtTokens(ev.total_tokens)} {isExpanded ? '▲' : '▼'}
@@ -276,7 +280,7 @@ const TraceDetailModal: React.FC = ({ traceId, onClose }) {isExpanded && (
{allMsgs.length === 0 && ( -

无消息数据

+

{t('cl.noMessageData')}

)} {allMsgs.map((msg: any, mi: number) => (
@@ -314,6 +318,8 @@ interface TraceSubTableProps { const PAGE_SIZE = 10; const TraceSubTable: React.FC = ({ sessionId, conversationInterruptionCounts, startNs, endNs, onResolvedEvent }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); const navigate = useNavigate(); const [traces, setTraces] = useState([]); const [loading, setLoading] = useState(true); @@ -414,7 +420,7 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn return (
); @@ -433,14 +439,14 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn @@ -448,7 +454,7 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn {traces.length === 0 && ( )} @@ -489,13 +495,13 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn
{fmtTokens(tr.total_output_tokens)}
-
{nsToDate(tr.start_ns)}
+
{nsToDate(tr.start_ns, locale)}
@@ -525,12 +531,12 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn ) : lookupFailed ? ( - 加载失败 + {t('cl.loadFailed')} ) : ( - '评估' + t('cl.eval') )} ); @@ -587,20 +593,20 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn ))} - {/* 分页控制 */} + {/* Pagination controls */} {totalPages > 1 && (
@@ -720,14 +726,15 @@ interface TokenTimeseriesChartProps { } const TOKEN_SERIES = [ - { key: 'input', name: '输入 Token', color: '#3b82f6' }, - { key: 'output', name: '输出 Token', color: '#10b981' }, - { key: 'total', name: '总 Token', color: '#6366f1' }, + { key: 'input', nameKey: 'cl.inputTokens' as const, color: '#3b82f6' }, + { key: 'output', nameKey: 'cl.outputTokens' as const, color: '#10b981' }, + { key: 'total', nameKey: 'cl.totalTokens' as const, color: '#6366f1' }, ] as const; const TokenTimeseriesChart: React.FC = ({ data, startNs, endNs, bucketCount = 30, }) => { + const { t } = useI18n(); const spanMs = (endNs - startNs) / 1_000_000; const filled = fillTokenBuckets(data, startNs, endNs, bucketCount); const chartData: TokenChartData[] = filled.map((b) => ({ @@ -750,7 +757,7 @@ const TokenTimeseriesChart: React.FC = ({ if (filled.every((b) => b.total_tokens === 0)) { return (
- 暂无时序数据 + {t('cl.noTimeseriesData')}
); } @@ -774,12 +781,12 @@ const TokenTimeseriesChart: React.FC = ({ )} /> - {TOKEN_SERIES.map(({ key, name, color }) => ( + {TOKEN_SERIES.map(({ key, nameKey, color }) => ( = ({ data, startNs, endNs, bucketCount = 30, }) => { + const { t } = useI18n(); const spanMs = (endNs - startNs) / 1_000_000; const models = Array.from(new Set(data.map((d) => d.model))).sort(); const filled = fillModelBuckets(data, startNs, endNs, bucketCount, models); @@ -829,7 +837,7 @@ const ModelTimeseriesChart: React.FC = ({ if (models.length === 0) { return (
- 暂无模型时序数据 + {t('cl.noModelTimeseriesData')}
); } @@ -875,6 +883,8 @@ export interface ConversationListProps { } export const ConversationList: React.FC = () => { + const { t } = useI18n(); + const locale = useLocaleTag(); const [searchParams, setSearchParams] = useSearchParams(); // Restore state from URL params (set when navigating to detail page) @@ -1069,12 +1079,12 @@ export const ConversationList: React.FC = () => { try { await runQuery(startNs, endNs, agent); } catch (e: any) { - setError(e.message ?? '查询失败'); + setError(e.message ?? t('cl.queryFailed')); } finally { setLoading(false); setTimeseriesLoading(false); } - }, [startMs, selectedAgent, syncParams, runQuery]); + }, [startMs, selectedAgent, syncParams, runQuery, t]); // Auto-load on mount: show all records for the default time range immediately const hasRestoredRef = React.useRef(false); @@ -1089,7 +1099,7 @@ export const ConversationList: React.FC = () => { setTimeseriesLoading(true); setQueryRangeNs([startNs, endNs]); runQuery(startNs, endNs, agent).catch((e: any) => { - setError(e.message ?? '查询失败'); + setError(e.message ?? t('cl.queryFailed')); }).finally(() => { setLoading(false); setTimeseriesLoading(false); @@ -1107,16 +1117,16 @@ export const ConversationList: React.FC = () => { {/* ── Filter bar ── */}
{/* Time range */} - - + + {/* Quick presets */}
{[ - { label: '最近 1h', ms: 3600 * 1000 }, - { label: '最近 6h', ms: 6 * 3600 * 1000 }, - { label: '最近 24h', ms: 24 * 3600 * 1000 }, - { label: '最近 7d', ms: 7 * 24 * 3600 * 1000 }, + { label: t('common.last1h'), ms: 3600 * 1000 }, + { label: t('common.last6h'), ms: 6 * 3600 * 1000 }, + { label: t('common.last24h'), ms: 24 * 3600 * 1000 }, + { label: t('common.last7d'), ms: 7 * 24 * 3600 * 1000 }, ].map(({ label, ms }) => (
@@ -1174,24 +1184,24 @@ export const ConversationList: React.FC = () => { {/* Summary cards */}
-

Sessions

+

{t('cl.sessions')}

{sessions.length}

-

总输入 Token

+

{t('cl.totalInputTokens')}

{fmtTokens(totalInputTokens)}

-

总输出 Token

+

{t('cl.totalOutputTokens')}

{fmtTokens(totalOutputTokens)}

- {/* ── 异常中断卡片 ── */} + {/* ── Interruption card ── */}
-

异常中断

+

{t('cl.interruptions')}

{interruptionCount === null ? (

) : ( @@ -1201,10 +1211,10 @@ export const ConversationList: React.FC = () => {
{( [ - { key: 'critical', label: '严重', bg: 'bg-red-100 text-red-700 border border-red-300' }, - { key: 'high', label: '重要', bg: 'bg-orange-100 text-orange-700 border border-orange-300' }, - { key: 'medium', label: '中等', bg: 'bg-yellow-100 text-yellow-700 border border-yellow-300' }, - { key: 'low', label: '轻微', bg: 'bg-blue-100 text-blue-700 border border-blue-300' }, + { key: 'critical', label: t('common.critical'), bg: 'bg-red-100 text-red-700 border border-red-300' }, + { key: 'high', label: t('common.high'), bg: 'bg-orange-100 text-orange-700 border border-orange-300' }, + { key: 'medium', label: t('common.medium'), bg: 'bg-yellow-100 text-yellow-700 border border-yellow-300' }, + { key: 'low', label: t('common.low'), bg: 'bg-blue-100 text-blue-700 border border-blue-300' }, ] as const ).map(({ key, label, bg }) => { const cnt = interruptionCount.by_severity[key]; @@ -1212,7 +1222,7 @@ export const ConversationList: React.FC = () => { const tooltipLines = interruptionStats .filter((s) => s.severity === key) .sort((a, b) => b.count - a.count) - .map((s) => `${INTERRUPTION_TYPE_CN[s.interruption_type] ?? s.interruption_type}: ${s.count} 次`); + .map((s) => `${s.interruption_type}: ${s.count}`); return ( = () => {
{/* Token time-series */}
-

Token 时序(输入 / 输出 / 总计)

+

{t('cl.tokenTimeseries')}

{timeseriesLoading ? ( -
加载中...
+
{t('common.loading')}
) : ( )} @@ -1251,9 +1261,9 @@ export const ConversationList: React.FC = () => { {/* Model token time-series */}
-

模型 Token 时序(堆叠)

+

{t('cl.modelTokenTimeseries')}

{timeseriesLoading ? ( -
加载中...
+
{t('common.loading')}
) : ( )} @@ -1268,36 +1278,36 @@ export const ConversationList: React.FC = () => {
@@ -1306,8 +1316,8 @@ export const ConversationList: React.FC = () => { )} @@ -1386,7 +1396,7 @@ export const ConversationList: React.FC = () => { {fmtTokens(sess.total_output_tokens)} @@ -364,6 +382,8 @@ const SessionRow: React.FC<{ initialExpanded?: boolean; rowRef?: React.Ref; }> = ({ session, initialExpanded = false, rowRef }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); const [expanded, setExpanded] = useState(initialExpanded); return ( @@ -395,13 +415,13 @@ const SessionRow: React.FC<{ @@ -464,6 +484,8 @@ const SessionRow: React.FC<{ // ─── Main page ──────────────────────────────────────────────────────────────── export const TokenSavingsPage: React.FC = () => { + const { t } = useI18n(); + const locale = useLocaleTag(); const [searchParams] = useSearchParams(); const now = Date.now(); @@ -513,11 +535,11 @@ export const TokenSavingsPage: React.FC = () => { setStatsAvailable(resp.stats_available); setTips(resp.optimization_tips ?? []); } catch (e: any) { - setError(e.message || 'Failed to fetch token savings'); + setError(e.message || t('ts.fetchFailed')); } finally { setLoading(false); } - }, [startMs, endMs, selectedAgent]); + }, [startMs, endMs, selectedAgent, t]); // Auto-query on mount when navigated from homepage with URL params const hasAutoQueriedRef = useRef(false); @@ -551,19 +573,14 @@ export const TokenSavingsPage: React.FC = () => { {/* ── Filter bar ── */}
{/* Time range */} - - + + {/* Quick presets */}
- {[ - { label: '最近 1h', ms: 3600 * 1000 }, - { label: '最近 6h', ms: 6 * 3600 * 1000 }, - { label: '最近 24h', ms: 24 * 3600 * 1000 }, - { label: '最近 7d', ms: 7 * 24 * 3600 * 1000 }, - ].map(({ label, ms }) => ( + {TIME_PRESETS.map(({ labelKey, ms }) => ( ))}
{/* Agent selector */}
- +
@@ -883,8 +900,8 @@ export const TokenSavingsPage: React.FC = () => { /* Prompt before first query */
-

请选择时间范围,然后点击「查询」

-

查看 Token 节省效果

+

{t('ts.selectTimeRange')}

+

{t('ts.viewSavings')}

)} diff --git a/src/agentsight/dashboard/src/pages/security/EventDetailDrawer.tsx b/src/agentsight/dashboard/src/pages/security/EventDetailDrawer.tsx index d84d1e14d2..9104175bbd 100644 --- a/src/agentsight/dashboard/src/pages/security/EventDetailDrawer.tsx +++ b/src/agentsight/dashboard/src/pages/security/EventDetailDrawer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityApiResponse, SecurityEventDetailResponse, @@ -15,6 +16,7 @@ export const EventDetailDrawer: React.FC<{ onClose: () => void; onRetry: () => void; }> = ({ eventId, detail, loading, error, onClose, onRetry }) => { + const { t } = useI18n(); const event = detail?.data.event; return (
@@ -22,7 +24,7 @@ export const EventDetailDrawer: React.FC<{
-

安全事件详情

+

{t('sec.securityEventDetails')}

{detail && }

{eventId}

@@ -30,14 +32,14 @@ export const EventDetailDrawer: React.FC<{
- {loading &&
加载详情...
} + {loading &&
{t('sec.loadingDetails')}
} {error && (

{error}

@@ -45,39 +47,39 @@ export const EventDetailDrawer: React.FC<{ onClick={onRetry} className="mt-3 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50" > - 重试 + {t('common.retry')}
)} {!loading && !error && detail?.state === 'not_found' && (
- 该安全事件已不存在。 + {t('sec.eventNoLongerExists')}
)} {!loading && !error && event && (
{(event.redacted || detail.state === 'redacted') && (
- 敏感字段已由 agent-sec daemon 脱敏。 + {t('sec.redactedByDaemon')}
)} {(event.truncated || detail.state === 'truncated') && (
- 部分字段因大小限制被截断。 + {t('sec.truncatedBySize')}
)}
{[ - ['时间', fmtTime(event)], - ['类别', event.category ?? '-'], - ['结果', event.result ?? '-'], - ['Verdict', securityEventVerdict(event)], - ['Session', event.session_id ?? '-'], - ['Run', event.run_id ?? '-'], - ['Call', event.call_id ?? '-'], - ['Tool Call', event.tool_call_id ?? '-'], - ['Trace', event.trace_id ?? '-'], + [t('common.time'), fmtTime(event)], + [t('sec.category'), event.category ?? '-'], + [t('sec.result'), event.result ?? '-'], + [t('sec.verdict'), securityEventVerdict(event)], + [t('sec.session'), event.session_id ?? '-'], + [t('sec.run'), event.run_id ?? '-'], + [t('sec.call'), event.call_id ?? '-'], + [t('sec.toolCall'), event.tool_call_id ?? '-'], + [t('sec.trace'), event.trace_id ?? '-'], ].map(([label, value]) => (

{label}

@@ -87,7 +89,7 @@ export const EventDetailDrawer: React.FC<{
-

Details

+

{t('common.details')}

                   {JSON.stringify(event.details ?? event, null, 2)}
                 
diff --git a/src/agentsight/dashboard/src/pages/security/EventTable.tsx b/src/agentsight/dashboard/src/pages/security/EventTable.tsx index 08d25f8231..051fbe338e 100644 --- a/src/agentsight/dashboard/src/pages/security/EventTable.tsx +++ b/src/agentsight/dashboard/src/pages/security/EventTable.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityApiResponse, SecurityEventRecord, @@ -23,6 +24,7 @@ export const EventTable: React.FC<{ onPage: (offset: number) => void; onViewTimeline?: (sessionId: string, runId: string) => void; }> = ({ response, loading, error, onSelect, onPage, onViewTimeline }) => { + const { t } = useI18n(); const data = response?.data; const items = data?.items ?? []; const previousOffset = Math.max(0, (data?.offset ?? 0) - (data?.limit ?? EVENT_PAGE_SIZE)); @@ -33,33 +35,33 @@ export const EventTable: React.FC<{
-

安全事件

+

{t('sec.securityEvents')}

{response && }
- Total {fmtNumber(data?.total)} + {t('sec.totalEvents', { n: fmtNumber(data?.total) })}
{error && (
{error}
)} {loading && items.length === 0 && ( -
加载安全事件...
+
{t('sec.loadingEvents')}
)} {!loading && !error && items.length === 0 && ( -
所选过滤条件下暂无安全事件
+
{t('sec.noEventsFiltered')}
)} {items.length > 0 && (
时间Agent类型严重度SessionConversation状态操作{t('common.time')}{t('common.agent')}{t('common.type')}{t('common.severity')}{t('common.session')}{t('common.conversation')}{t('common.status')}{t('common.actions')}
- 加载 Trace 列表... + {t('cl.loadingTraces')}
-
Conversation ID
-
用户请求
-
输入 Token
-
输出 Token
-
开始时间
-
操作
-
质量评估
-
中断
+
{t('cl.conversationId')}
+
{t('cl.userQuery')}
+
{t('cl.inputTokens')}
+
{t('cl.outputTokens')}
+
{t('cl.startTime')}
+
{t('cl.actions')}
+
{t('cl.qualityEval')}
+
{t('cl.interrupts')}
- 该 Session 下暂无 Trace + {t('cl.noTraces')}
- {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, traces.length)} / {traces.length} 条 + {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, traces.length)} / {traces.length} {Array.from({ length: totalPages }, (_, i) => (
- Session ID + {t('cl.sessionId')} - Agent + {t('cl.agent')} - Model + {t('cl.model')} - 对话数 + {t('cl.conversations')} - 输入 Token + {t('cl.inputTokens')} - 节省 Token + {t('cl.savedTokens')} - 输出 Token + {t('cl.outputTokens')} - 最近活跃 + {t('cl.lastActive')} - 操作 + {t('cl.actions')} - 中断 + {t('cl.interrupts')}
🔍
-

所选时间范围内暂无 Session 数据

-

请确认 agentsight 服务已启动并有数据写入

+

{t('cl.noSessions')}

+

{t('cl.ensureServiceRunning')}

- {nsToDate(sess.last_seen_ns)} + {nsToDate(sess.last_seen_ns, locale)} = () => { onClick={(e) => e.stopPropagation()} className="px-3 py-1 bg-white border border-blue-300 text-blue-700 rounded-lg text-xs hover:bg-blue-50 transition-colors whitespace-nowrap" > - 详情 + {t('common.details')} e.stopPropagation()}> @@ -1435,14 +1445,14 @@ export const ConversationList: React.FC = () => { return (
- {sessionPage * SESSION_PAGE_SIZE + 1}–{Math.min((sessionPage + 1) * SESSION_PAGE_SIZE, sessions.length)} / {sessions.length} 条 + {sessionPage * SESSION_PAGE_SIZE + 1}–{Math.min((sessionPage + 1) * SESSION_PAGE_SIZE, sessions.length)} / {sessions.length} {Array.from({ length: sessionTotalPages }, (_, i) => (
); diff --git a/src/agentsight/dashboard/src/pages/SecurityObservabilityPage.tsx b/src/agentsight/dashboard/src/pages/SecurityObservabilityPage.tsx index a2c1f3163b..50dbcab681 100644 --- a/src/agentsight/dashboard/src/pages/SecurityObservabilityPage.tsx +++ b/src/agentsight/dashboard/src/pages/SecurityObservabilityPage.tsx @@ -40,12 +40,15 @@ import { mapToCountItems, msToNs, } from './security/utils'; +import { useI18n } from '../i18n'; +import type { MessageKey } from '../i18n'; function isSecurityAvailableState(state: string | null | undefined): boolean { return state === 'daemon_reachable'; } export const SecurityObservabilityPage: React.FC = () => { + const { t } = useI18n(); const now = Date.now(); const [startMs, setStartMs] = useState(now - 24 * 3600 * 1000); const [endMs, setEndMs] = useState(now); @@ -110,12 +113,12 @@ export const SecurityObservabilityPage: React.FC = () => { return nextStatus; } catch (error) { setStatus(null); - setStatusError(errorMessage(error)); + setStatusError(errorMessage(error, t)); return null; } finally { setStatusLoading(false); } - }, []); + }, [t]); const loadOverview = useCallback(async () => { setOverviewLoading(true); @@ -139,7 +142,7 @@ export const SecurityObservabilityPage: React.FC = () => { setter(result.value); return result.value; } - errors.push(errorMessage(result.reason)); + errors.push(errorMessage(result.reason, t)); return null; }; @@ -163,7 +166,7 @@ export const SecurityObservabilityPage: React.FC = () => { setOverviewError(errors.length > 0 ? errors.join('; ') : null); setOverviewLoading(false); - }, [rangeParams]); + }, [rangeParams, t]); const loadEvents = useCallback(async (offset: number, filters = appliedEventFilters) => { if (!isAvailable) return; @@ -179,11 +182,11 @@ export const SecurityObservabilityPage: React.FC = () => { }); setEvents(response); } catch (error) { - setEventsError(errorMessage(error)); + setEventsError(errorMessage(error, t)); } finally { setEventsLoading(false); } - }, [appliedEventFilters, isAvailable, rangeParams]); + }, [appliedEventFilters, isAvailable, rangeParams, t]); const loadSessions = useCallback(async () => { if (!isAvailable) return; @@ -197,11 +200,11 @@ export const SecurityObservabilityPage: React.FC = () => { ? current : response.data.items[0]?.session_id ?? null); } catch (error) { - setSessionsError(errorMessage(error)); + setSessionsError(errorMessage(error, t)); } finally { setSessionsLoading(false); } - }, [isAvailable, rangeParams]); + }, [isAvailable, rangeParams, t]); const loadEventDetail = useCallback(async (eventId: string) => { setEventDetailLoading(true); @@ -210,11 +213,11 @@ export const SecurityObservabilityPage: React.FC = () => { try { setEventDetail(await fetchSecurityEvent(eventId)); } catch (error) { - setEventDetailError(errorMessage(error)); + setEventDetailError(errorMessage(error, t)); } finally { setEventDetailLoading(false); } - }, []); + }, [t]); useEffect(() => { loadStatus(); @@ -264,7 +267,7 @@ export const SecurityObservabilityPage: React.FC = () => { if (!cancelled) setSessionEvents(response); }) .catch((error) => { - if (!cancelled) setSessionEventsError(errorMessage(error)); + if (!cancelled) setSessionEventsError(errorMessage(error, t)); }) .finally(() => { if (!cancelled) setSessionEventsLoading(false); @@ -273,7 +276,7 @@ export const SecurityObservabilityPage: React.FC = () => { return () => { cancelled = true; }; - }, [activeTab, isAvailable, rangeParams, selectedSessionId, timelineRefreshNonce]); + }, [activeTab, isAvailable, rangeParams, selectedSessionId, timelineRefreshNonce, t]); useEffect(() => { if (!isAvailable || activeTab !== 'timeline' || !selectedSessionId) { @@ -297,7 +300,7 @@ export const SecurityObservabilityPage: React.FC = () => { }) .catch((error) => { if (!cancelled) { - setRunsError(errorMessage(error)); + setRunsError(errorMessage(error, t)); setSelectedRunId(null); } }) @@ -308,7 +311,7 @@ export const SecurityObservabilityPage: React.FC = () => { return () => { cancelled = true; }; - }, [activeTab, isAvailable, rangeParams, selectedSessionId, timelineRefreshNonce]); + }, [activeTab, isAvailable, rangeParams, selectedSessionId, timelineRefreshNonce, t]); useEffect(() => { if (!isAvailable || activeTab !== 'timeline' || !selectedSessionId || !selectedRunId) { @@ -329,7 +332,7 @@ export const SecurityObservabilityPage: React.FC = () => { if (!cancelled) setTimeline(response); }) .catch((error) => { - if (!cancelled) setTimelineError(errorMessage(error)); + if (!cancelled) setTimelineError(errorMessage(error, t)); }) .finally(() => { if (!cancelled) setTimelineLoading(false); @@ -338,7 +341,7 @@ export const SecurityObservabilityPage: React.FC = () => { return () => { cancelled = true; }; - }, [activeTab, isAvailable, rangeParams, selectedRunId, selectedSessionId, timelineRefreshNonce]); + }, [activeTab, isAvailable, rangeParams, selectedRunId, selectedSessionId, timelineRefreshNonce, t]); const handleRefresh = useCallback(async () => { const nextStatus = await loadStatus(); @@ -349,7 +352,7 @@ export const SecurityObservabilityPage: React.FC = () => { await loadSessions(); setTimelineRefreshNonce((current) => current + 1); } - }, [activeTab, loadEvents, loadOverview, loadSessions, loadStatus]); + }, [activeTab, loadEvents, loadOverview, loadSessions, loadStatus, t]); const overviewEvents = recentEvents?.data.items ?? summary?.data.latest_events ?? []; const latestEvents = overviewEvents.slice(0, 10); @@ -368,8 +371,8 @@ export const SecurityObservabilityPage: React.FC = () => {
-

安全可观测

-

Security Observability / agent-sec daemon

+

{t('sec.securityObservability')}

+

{t('sec.securityObservabilityDesc')}

{status && } @@ -378,21 +381,21 @@ export const SecurityObservabilityPage: React.FC = () => { disabled={statusLoading || overviewLoading || eventsLoading} className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50" > - 刷新 + {t('sec.refresh')}
- - + +
{[ - { label: '最近 1h', ms: 3600 * 1000 }, - { label: '最近 6h', ms: 6 * 3600 * 1000 }, - { label: '最近 24h', ms: 24 * 3600 * 1000 }, - { label: '最近 7d', ms: 7 * 24 * 3600 * 1000 }, + { label: t('common.last1h'), ms: 3600 * 1000 }, + { label: t('common.last6h'), ms: 6 * 3600 * 1000 }, + { label: t('common.last24h'), ms: 24 * 3600 * 1000 }, + { label: t('common.last7d'), ms: 7 * 24 * 3600 * 1000 }, ].map((item) => ( ); }; @@ -94,28 +97,39 @@ type OptimizationCategory = 'tool_output' | 'mcp_response'; // ─── Category config ────────────────────────────────────────────────────────── -const CATEGORY_CONFIG: Record = { - tool_output: { label: '工具输出', color: 'text-orange-700', bg: 'bg-orange-100' }, - mcp_response: { label: 'MCP输出', color: 'text-violet-700', bg: 'bg-violet-100' }, +const CATEGORY_CONFIG: Record = { + tool_output: { labelKey: 'ts.toolOutput', color: 'text-orange-700', bg: 'bg-orange-100' }, + mcp_response: { labelKey: 'ts.mcpOutput', color: 'text-violet-700', bg: 'bg-violet-100' }, }; // ─── Strategy config ───────────────────────────────────────────────────────── -const STRATEGY_CONFIG: Record = { - 'compress-schema': { label: 'Schema 压缩', color: 'text-blue-700', bg: 'bg-blue-100', pie: '#3b82f6', tooltip: '精简工具/MCP 接口定义,减少上下文体积' }, - 'compress-response': { label: '响应压缩', color: 'text-violet-700', bg: 'bg-violet-100', pie: '#8b5cf6', tooltip: '清理响应冗余字段,保留语义关键内容' }, - 'rewrite-command': { label: '命令重写', color: 'text-orange-700', bg: 'bg-orange-100', pie: '#f59e0b', tooltip: '将工具命令重写为更精简的等价形式' }, - 'compress-toon': { label: 'TOON 编码', color: 'text-teal-700', bg: 'bg-teal-100', pie: '#14b8a6', tooltip: '将 JSON 输出转换为紧凑 TOON 表格文本' }, +const STRATEGY_CONFIG: Record = { + 'compress-schema': { labelKey: 'ts.schemaCompression', color: 'text-blue-700', bg: 'bg-blue-100', pie: '#3b82f6', tooltipKey: 'ts.schemaCompressionTip' }, + 'compress-response': { labelKey: 'ts.responseCompression', color: 'text-violet-700', bg: 'bg-violet-100', pie: '#8b5cf6', tooltipKey: 'ts.responseCompressionTip' }, + 'rewrite-command': { labelKey: 'ts.commandRewrite', color: 'text-orange-700', bg: 'bg-orange-100', pie: '#f59e0b', tooltipKey: 'ts.commandRewriteTip' }, + 'compress-toon': { labelKey: 'ts.toonEncoding', color: 'text-teal-700', bg: 'bg-teal-100', pie: '#14b8a6', tooltipKey: 'ts.toonEncodingTip' }, }; // ─── Pie chart data ─────────────────────────────────────────────────────────── -const PIE_COLORS = ['#3b82f6', '#10b981']; // 输入蓝, 输出绿 -const SAVED_PIE_COLORS = ['#f59e0b', '#8b5cf6']; // 工具橙, MCP紫 +const PIE_COLORS = ['#3b82f6', '#10b981']; // input blue, output green +const SAVED_PIE_COLORS = ['#f59e0b', '#8b5cf6']; // tool orange, MCP violet + +// ─── Time range presets ─────────────────────────────────────────────────────── + +const TIME_PRESETS: { labelKey: MessageKey; ms: number }[] = [ + { labelKey: 'common.last1h', ms: 3600 * 1000 }, + { labelKey: 'common.last6h', ms: 6 * 3600 * 1000 }, + { labelKey: 'common.last24h', ms: 24 * 3600 * 1000 }, + { labelKey: 'common.last7d', ms: 7 * 24 * 3600 * 1000 }, +]; // ─── Diff view (split / unified toggle) ────────────────────────────────────── const DiffView: React.FC<{ item: OptimizationItem }> = ({ item }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); const diffLines = item.diff_lines ?? []; const addedCount = diffLines.filter(l => l.type === 'add').length; const removedCount = diffLines.filter(l => l.type === 'remove').length; @@ -146,11 +160,11 @@ const DiffView: React.FC<{ item: OptimizationItem }> = ({ item }) => {

{item.explanation}

- 压缩率 {item.compression_ratio.toFixed(1)}% + {t('ts.compressionRatio')} {item.compression_ratio.toFixed(1)}% {' · '} - 影响后续 {item.compounding_turns} 轮调用 + {t('ts.affectsCalls', { n: item.compounding_turns })} {' · '} - 复合节省 {fmtTokens(item.compounded_saved)} tokens + {t('ts.compoundedSavings', { n: fmtTokens(item.compounded_saved, locale) })}

@@ -178,15 +192,15 @@ const DiffView: React.FC<{ item: OptimizationItem }> = ({ item }) => { ) : (
-
原始内容
+
{t('common.original')}
-                {item.before_text || '无变更'}
+                {item.before_text || t('common.noChange')}
               
-
优化后
+
{t('common.optimized')}
-                {item.after_text || '无变更'}
+                {item.after_text || t('common.noChange')}
               
@@ -196,8 +210,8 @@ const DiffView: React.FC<{ item: OptimizationItem }> = ({ item }) => { {/* Stats footer */} {diffLines.length > 0 && (
- -{removedCount} 行移除 - +{addedCount} 行新增 + {t('common.linesRemoved', { n: removedCount })} + {t('common.linesAdded', { n: addedCount })}
)}
@@ -213,11 +227,12 @@ const TIP_STYLE: Record = ({ tips }) => { + const { t } = useI18n(); if (tips.length === 0) return null; return (

- 🎯 优化建议 + {t('ts.optimizationTips')}

{tips.map((tip, idx) => { @@ -240,6 +255,8 @@ const OptimizationTipsPanel: React.FC<{ tips: OptimizationTip[] }> = ({ tips }) // ─── Savings Breakdown Panel ───────────────────────────────────────────────── const SavingsBreakdownPanel: React.FC<{ sessions: SessionSavings[] }> = ({ sessions }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); // Get top 5 optimization items across all sessions by compounded_saved const allItems = sessions.flatMap(s => s.optimization_items.map(item => ({ @@ -259,7 +276,7 @@ const SavingsBreakdownPanel: React.FC<{ sessions: SessionSavings[] }> = ({ sessi return (

- 📊 节省排行 Top 5(按复合节省量) + {t('ts.savingsTop5')}

{topItems.map((item, idx) => { @@ -269,7 +286,7 @@ const SavingsBreakdownPanel: React.FC<{ sessions: SessionSavings[] }> = ({ sessi
#{idx + 1} - {cfg.label} + {t(cfg.labelKey)}
@@ -278,7 +295,7 @@ const SavingsBreakdownPanel: React.FC<{ sessions: SessionSavings[] }> = ({ sessi style={{ width: `${pct}%` }} /> - {fmtTokens(item.compounded_saved)} tokens + {t('common.tokens', { n: fmtTokens(item.compounded_saved, locale) })}
@@ -296,13 +313,14 @@ const SavingsBreakdownPanel: React.FC<{ sessions: SessionSavings[] }> = ({ sessi // ─── Optimization table row ─────────────────────────────────────────────────── const OptimizationTableRow: React.FC<{ item: OptimizationItem }> = ({ item }) => { + const { t } = useI18n(); + const locale = useLocaleTag(); const [expanded, setExpanded] = useState(false); const cfg = CATEGORY_CONFIG[item.category]; - const stratCfg = STRATEGY_CONFIG[item.strategy] ?? { - label: item.strategy_label || item.strategy, - color: 'text-gray-700', bg: 'bg-gray-100', pie: '#9ca3af', - tooltip: '', - }; + const stratConfig = STRATEGY_CONFIG[item.strategy]; + const stratStyle = stratConfig ?? { color: 'text-gray-700', bg: 'bg-gray-100', pie: '#9ca3af' }; + const stratLabel = stratConfig ? t(stratConfig.labelKey) : (item.strategy_label || item.strategy); + const stratTooltip = stratConfig ? t(stratConfig.tooltipKey) : ''; const savingsPercent = item.before_tokens > 0 ? ((item.before_tokens - item.after_tokens) / item.before_tokens * 100).toFixed(0) : '0'; @@ -312,37 +330,37 @@ const OptimizationTableRow: React.FC<{ item: OptimizationItem }> = ({ item }) =>
- {cfg.label} + {t(cfg.labelKey)} {item.title} - - {stratCfg.label} - {stratCfg.tooltip && ( + + {stratLabel} + {stratTooltip && ( - {stratCfg.tooltip} + {stratTooltip} )} - {fmtTokens(item.before_tokens)} + {fmtTokens(item.before_tokens, locale)} - {fmtTokens(item.after_tokens)} + {fmtTokens(item.after_tokens, locale)} - {fmtTokens(item.compounded_saved)} - (单轮 {savingsPercent}%) + {fmtTokens(item.compounded_saved, locale)} + {t('ts.singleTurn', { pct: savingsPercent })}
- {fmtTokens(session.total_input_tokens)} + {fmtTokens(session.total_input_tokens, locale)} - {fmtTokens(session.total_output_tokens)} + {fmtTokens(session.total_output_tokens, locale)} - {fmtTokens(session.compounded_saved)} + {fmtTokens(session.compounded_saved, locale)}
@@ -428,22 +448,22 @@ const SessionRow: React.FC<{
- 分类 + {t('ts.category')} - 节省策略 + {t('ts.savingsStrategy')} - 优化前 + {t('ts.before')} - 优化后 + {t('ts.optimizedCol')} - 节省 + {t('ts.savedCol')} - 详情 + {t('ts.detailsCol')}
- Session ID + {t('ts.sessionId')} - Agent + {t('common.agent')} - 输入 Token + {t('ts.inputTokens')} - 输出 Token + {t('ts.outputTokens')} - 已节省 + {t('ts.savedCol')} - 节省率 + {t('ts.savingsRateCol')}
- - - - - - - + + + + + + + @@ -96,7 +98,7 @@ export const EventTable: React.FC<{ onClick={(e) => { e.stopPropagation(); onViewTimeline(event.session_id!, event.run_id!); }} className="text-blue-600 hover:text-blue-800 hover:underline" > - 详情 + {t('common.details')} ) : ( shortId(event.run_id) @@ -123,14 +125,14 @@ export const EventTable: React.FC<{ disabled={!hasPrevious || loading} className="rounded border border-gray-300 px-3 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-40" > - 上一页 + {t('common.prev')} )} diff --git a/src/agentsight/dashboard/src/pages/security/EventsTab.tsx b/src/agentsight/dashboard/src/pages/security/EventsTab.tsx index 4dd09cabc7..f61d6bd041 100644 --- a/src/agentsight/dashboard/src/pages/security/EventsTab.tsx +++ b/src/agentsight/dashboard/src/pages/security/EventsTab.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityApiResponse, SecurityEventRecord, @@ -33,12 +34,14 @@ export const EventsTab: React.FC<{ loadEvents, onSelectEvent, onViewTimeline, -}) => ( +}) => { + const { t } = useI18n(); + return (
{[ - ['session_id', 'Session ID'], - ['run_id', 'Run ID'], + ['session_id', t('sec.sessionId')], + ['run_id', t('sec.runId')], ].map(([key, label]) => (
@@ -132,4 +135,5 @@ export const EventsTab: React.FC<{ onViewTimeline={onViewTimeline} />
-); + ); +}; diff --git a/src/agentsight/dashboard/src/pages/security/OverviewRiskSummary.tsx b/src/agentsight/dashboard/src/pages/security/OverviewRiskSummary.tsx index 7d1d068689..a574d36e9e 100644 --- a/src/agentsight/dashboard/src/pages/security/OverviewRiskSummary.tsx +++ b/src/agentsight/dashboard/src/pages/security/OverviewRiskSummary.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityApiResponse, SecurityCountItem, @@ -24,6 +25,7 @@ export const OverviewRiskSummary: React.FC<{ verdictItems: SecurityCountItem[]; onViewVerdict?: (verdict: string) => void; }> = ({ summary, eventsResponse, categoryItems, resultItems, verdictItems, onViewVerdict }) => { + const { t } = useI18n(); const totalEvents = eventsResponse?.data.total ?? summary?.total ?? 0; const verdictTotal = verdictItems.reduce((sum, item) => sum + item.count, 0); const maxVerdictCount = Math.max(1, ...verdictItems.map((item) => item.count)); @@ -40,34 +42,34 @@ export const OverviewRiskSummary: React.FC<{ .filter((item) => verdictTone(String(item.value)) === 'warning') .reduce((sum, item) => sum + item.count, 0); const nonPassRatio = fmtPercent(nonPassCount, verdictTotal); - const coverageText = `基于当前时间范围内 ${fmtNumber(verdictTotal)} 条含 verdict 事件统计`; + const coverageText = t('sec.verdictCoverage', { n: fmtNumber(verdictTotal) }); - let statusLabel = '暂无 verdict'; + let statusLabel = t('sec.noVerdictsYet'); let statusClasses = 'bg-gray-100 text-gray-700'; let summaryText = totalEvents === 0 - ? '当前时间范围内未记录安全事件,安全能力没有发现需要展示的检查结果。' - : `安全能力在当前时间范围内执行了 ${fmtNumber(totalEvents)} 次检查。`; + ? t('sec.summaryNoEvents') + : t('sec.summaryChecksPerformed', { n: fmtNumber(totalEvents) }); if (verdictTotal > 0 && nonPassCount === 0) { - statusLabel = '未发现非 pass verdict'; + statusLabel = t('sec.noNonPassFound'); statusClasses = 'bg-green-100 text-green-700'; - summaryText = `安全能力在当前时间范围内执行了 ${fmtNumber(totalEvents)} 次检查,已记录 verdict 的事件均为 pass。`; + summaryText = t('sec.summaryAllPass', { n: fmtNumber(totalEvents) }); } else if (riskCount > 0) { - statusLabel = `存在 ${fmtNumber(riskCount)} 个风险 verdict`; + statusLabel = t('sec.riskVerdictsFound', { n: fmtNumber(riskCount) }); statusClasses = 'bg-red-100 text-red-700'; - summaryText = `安全能力在当前时间范围内执行了 ${fmtNumber(totalEvents)} 次检查,其中 ${fmtNumber(nonPassCount)} 个 verdict 不是 pass,需要关注风险操作。`; + summaryText = t('sec.summaryRiskFound', { total: fmtNumber(totalEvents), nonPass: fmtNumber(nonPassCount) }); } else if (warningCount > 0 || nonPassCount > 0) { - statusLabel = `存在 ${fmtNumber(nonPassCount)} 个待关注 verdict`; + statusLabel = t('sec.verdictsToReview', { n: fmtNumber(nonPassCount) }); statusClasses = 'bg-amber-100 text-amber-800'; - summaryText = `安全能力在当前时间范围内执行了 ${fmtNumber(totalEvents)} 次检查,其中 ${fmtNumber(nonPassCount)} 个 verdict 不是 pass,建议复核。`; + summaryText = t('sec.summaryReviewRecommended', { total: fmtNumber(totalEvents), nonPass: fmtNumber(nonPassCount) }); } else if (totalEvents > 0) { - summaryText = `安全能力在当前时间范围内执行了 ${fmtNumber(totalEvents)} 次检查,但当前样本中暂无 verdict 明细。`; + summaryText = t('sec.summaryNoVerdictDetails', { n: fmtNumber(totalEvents) }); } return (
-

安全能力结论

+

{t('sec.capabilityVerdict')}

{summaryText}

{coverageText}

@@ -76,24 +78,24 @@ export const OverviewRiskSummary: React.FC<{
-

安全能力执行

+

{t('sec.checksExecuted')}

{fmtNumber(totalEvents)}

- 覆盖 {fmtNumber(summary?.affected_sessions)} Session / {fmtNumber(summary?.affected_runs)} Run + {t('sec.coveringSessionsRuns', { sessions: fmtNumber(summary?.affected_sessions), runs: fmtNumber(summary?.affected_runs) })}

-

风险操作占比

+

{t('sec.riskyOperationRatio')}

0 ? 'text-red-700' : 'text-green-700'}`}>{nonPassRatio}

- {fmtNumber(nonPassCount)} / {fmtNumber(verdictTotal)} 个 verdict 非 pass + {t('sec.verdictsNotPass', { nonPass: fmtNumber(nonPassCount), total: fmtNumber(verdictTotal) })}

-

需要关注

+

{t('sec.needsAttention')}

{fmtNumber(riskCount + warningCount)}

- 风险 {fmtNumber(riskCount)} / Warning {fmtNumber(warningCount)} + {t('sec.riskWarningCount', { risk: fmtNumber(riskCount), warning: fmtNumber(warningCount) })}

@@ -101,12 +103,12 @@ export const OverviewRiskSummary: React.FC<{
-

按 Verdict 聚类

- {verdictTotal > 0 && {fmtNumber(verdictTotal)} with verdict} +

{t('sec.clusteredByVerdict')}

+ {verdictTotal > 0 && {t('sec.withVerdict', { n: fmtNumber(verdictTotal) })}}
{verdictItems.length === 0 ? (
- 暂无 verdict 聚类数据 + {t('sec.noVerdictClusterData')}
) : (
@@ -123,7 +125,7 @@ export const OverviewRiskSummary: React.FC<{ onClick={() => onViewVerdict(verdictValue)} className="whitespace-nowrap text-xs text-blue-600 hover:text-blue-800 hover:underline" > - 详情 + {t('common.details')} ) : ( @@ -146,25 +148,25 @@ export const OverviewRiskSummary: React.FC<{
-

安全能力做了什么

+

{t('sec.whatCapabilityDid')}

{categoryItems.slice(0, 6).map((item) => ( {String(item.value)} {fmtNumber(item.count)} ))} - {categoryItems.length === 0 && 暂无检查动作数据} + {categoryItems.length === 0 && {t('sec.noCheckActionData')}}
-

执行状态

+

{t('sec.executionStatus')}

{resultItems.slice(0, 6).map((item) => ( {String(item.value)} {fmtNumber(item.count)} ))} - {resultItems.length === 0 && 暂无执行状态数据} + {resultItems.length === 0 && {t('sec.noExecutionStatusData')}}
diff --git a/src/agentsight/dashboard/src/pages/security/OverviewTab.tsx b/src/agentsight/dashboard/src/pages/security/OverviewTab.tsx index 4ccef0afa7..56767fd930 100644 --- a/src/agentsight/dashboard/src/pages/security/OverviewTab.tsx +++ b/src/agentsight/dashboard/src/pages/security/OverviewTab.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityApiResponse, SecurityCountItem, @@ -36,7 +37,9 @@ export const OverviewTab: React.FC<{ latestEvents, onSelectEvent, onViewVerdict, -}) => ( +}) => { + const { t } = useI18n(); + return (
{overviewError && (
@@ -45,13 +48,13 @@ export const OverviewTab: React.FC<{ )} {overviewLoading && !summary && (
- 加载安全汇总... + {t('sec.loadingSummary')}
)}
- - - + + +
{summary?.state === 'empty' && (
- 所选范围内暂无安全事件。 + {t('sec.noEventsInRange')}
)}
- - - + + +
-); + ); +}; diff --git a/src/agentsight/dashboard/src/pages/security/RecentEvents.tsx b/src/agentsight/dashboard/src/pages/security/RecentEvents.tsx index 3e67aacfff..c4460c0034 100644 --- a/src/agentsight/dashboard/src/pages/security/RecentEvents.tsx +++ b/src/agentsight/dashboard/src/pages/security/RecentEvents.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityEventRecord } from '../../utils/apiClient'; import { badgeClasses, fmtTime, securityEventVerdict, verdictBadgeClasses } from './utils'; @@ -6,22 +7,23 @@ export const RecentEvents: React.FC<{ events: SecurityEventRecord[]; onSelect: (eventId: string) => void; }> = ({ events, onSelect }) => { + const { t } = useI18n(); const columns = 'grid-cols-[128px_120px_minmax(180px,1fr)_110px_110px]'; return (
-

近期安全事件

+

{t('sec.recentSecurityEvents')}

{events.length === 0 ? ( -
所选范围内暂无安全事件
+
{t('sec.noEventsInRangeShort')}
) : (
- 时间 - Category - Event Type - Result - Verdict + {t('common.time')} + {t('sec.category')} + {t('sec.eventType')} + {t('sec.result')} + {t('sec.verdict')}
{events.map((event) => { diff --git a/src/agentsight/dashboard/src/pages/security/TimelineItem.tsx b/src/agentsight/dashboard/src/pages/security/TimelineItem.tsx index 7b1f2a0345..34fc28b421 100644 --- a/src/agentsight/dashboard/src/pages/security/TimelineItem.tsx +++ b/src/agentsight/dashboard/src/pages/security/TimelineItem.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityTimelineItem } from '../../utils/apiClient'; import { badgeClasses, @@ -17,6 +18,7 @@ export const TimelineItem: React.FC<{ observabilityItemsById: Map; onSelectEvent: (eventId: string) => void; }> = ({ item, observabilityItemsById, onSelectEvent }) => { + const { t } = useI18n(); const securityEvent = item.kind === 'security' ? item.event : undefined; const observabilityContext = timelineObservabilityContext(item, observabilityItemsById); const eventTitle = securityEvent?.event_type ?? securityEvent?.event_id; @@ -51,17 +53,17 @@ export const TimelineItem: React.FC<{

{title}

{securityEvent && observabilityContext.hook && ( - observability {observabilityContext.hook} + {t('sec.observabilityHook', { hook: observabilityContext.hook })} )} - {item.redacted && redacted} - {item.truncated && truncated} + {item.redacted && {t('sec.redacted')}} + {item.truncated && {t('sec.truncated')}}

{fmtTime(item)}

{item.match && ( - match {recordPreview(item.match.reason)} + {t('sec.matchReason', { reason: recordPreview(item.match.reason) })} )}
@@ -83,9 +85,9 @@ export const TimelineItem: React.FC<{
{detailRows.length > 0 && ( @@ -104,19 +106,19 @@ export const TimelineItem: React.FC<{ )} {correlated.length > 0 && (
- 关联安全事件 {correlated.length} 条 + {t('sec.correlatedEvents', { n: correlated.length })}
)}
- session {shortId(sessionId)} - run {shortId(runId)} - tool {shortId(toolCallId)} + {t('sec.sessionLabel', { id: shortId(sessionId) })} + {t('sec.runLabel', { id: shortId(runId) })} + {t('sec.toolLabel', { id: shortId(toolCallId) })}
{(itemMetadata || itemMetrics) && (
- metadata / metrics + {t('sec.metadataMetrics')}
               {JSON.stringify({ metadata: itemMetadata, metrics: itemMetrics }, null, 2)}
             
diff --git a/src/agentsight/dashboard/src/pages/security/TimelineSessionOverview.tsx b/src/agentsight/dashboard/src/pages/security/TimelineSessionOverview.tsx index 1eb6ce4428..c4accd79fd 100644 --- a/src/agentsight/dashboard/src/pages/security/TimelineSessionOverview.tsx +++ b/src/agentsight/dashboard/src/pages/security/TimelineSessionOverview.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityApiResponse, SecurityEventRecord, @@ -23,6 +24,7 @@ export const TimelineSessionOverview: React.FC<{ loading: boolean; error: string | null; }> = ({ session, run, eventsResponse, loading, error }) => { + const { t } = useI18n(); const events = eventsResponse?.data.items ?? []; const totalSecurityEvents = eventsResponse?.data.total ?? session?.security_event_count ?? 0; const verdictItems = verdictCountItems(events); @@ -33,41 +35,41 @@ export const TimelineSessionOverview: React.FC<{ }).length; const hasRiskVerdict = events.some((event) => verdictTone(securityEventVerdict(event)) === 'risk'); - let statusLabel = '选择 Session 后统计 verdict'; + let statusLabel = t('sec.selectSessionToAggregate'); let statusClasses = 'bg-gray-100 text-gray-700'; if (loading) { - statusLabel = 'Verdict 统计中...'; + statusLabel = t('sec.aggregatingVerdicts'); } else if (error) { - statusLabel = 'Verdict 统计失败'; + statusLabel = t('sec.verdictAggregationFailed'); statusClasses = 'bg-red-100 text-red-700'; } else if (!session) { - statusLabel = '未选择 Session'; + statusLabel = t('sec.noSessionSelected'); } else if (totalSecurityEvents === 0) { - statusLabel = '无安全事件'; + statusLabel = t('sec.noSecurityEventsShort'); } else if (verdictTotal === 0) { - statusLabel = '暂无 verdict'; + statusLabel = t('sec.noVerdictsYet'); } else if (nonPassCount === 0) { - statusLabel = '全部 verdict 为 pass'; + statusLabel = t('sec.allVerdictsPass'); statusClasses = 'bg-green-100 text-green-700'; } else { - statusLabel = `存在 ${nonPassCount} 个非 pass verdict`; + statusLabel = t('sec.nonPassVerdicts', { n: nonPassCount }); statusClasses = hasRiskVerdict ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-800'; } const metrics = [ - ['Session', shortId(session?.session_id, 18)], - ['当前 Run', shortId(run?.run_id, 18)], - ['Turns', fmtNumber(session?.turn_count)], - ['观测事件', fmtNumber(session?.observability_event_count)], - ['安全事件', fmtNumber(totalSecurityEvents)], + [t('sec.session'), shortId(session?.session_id, 18)], + [t('sec.currentRun'), shortId(run?.run_id, 18)], + [t('sec.turns'), fmtNumber(session?.turn_count)], + [t('sec.observabilityEvents'), fmtNumber(session?.observability_event_count)], + [t('sec.securityEventsCount'), fmtNumber(totalSecurityEvents)], ]; return (
-

Session 总览

-

按当前时间范围统计所选 session 的安全事件 verdict

+

{t('sec.sessionOverview')}

+

{t('sec.sessionOverviewDesc')}

{statusLabel} diff --git a/src/agentsight/dashboard/src/pages/security/TimelineTab.tsx b/src/agentsight/dashboard/src/pages/security/TimelineTab.tsx index 6df2714792..15e409c582 100644 --- a/src/agentsight/dashboard/src/pages/security/TimelineTab.tsx +++ b/src/agentsight/dashboard/src/pages/security/TimelineTab.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useI18n } from '../../i18n'; import type { SecurityApiResponse, SecurityEventRecord, @@ -54,12 +55,14 @@ export const TimelineTab: React.FC<{ timelineError, observabilityItemsById, onSelectEvent, -}) => ( +}) => { + const { t } = useI18n(); + return (
) : (

- 原始进程身份已失效,且当前未发现执行器允许的同一 Agent 在线进程。 + {t('cont.target.noneAvailable')}

))} @@ -373,49 +389,60 @@ export const ContainmentDialog: React.FC = ({

)} - {!liveExistingAction &&
- 拦截时长 - - +
+ )} )} {error && ( -

+

{error}

)} @@ -428,16 +455,18 @@ export const ContainmentDialog: React.FC = ({ disabled={submitting} className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 disabled:opacity-40" > - {liveExistingAction ? '关闭' : '取消'} + {liveExistingAction ? t('cont.footer.close') : t('cont.footer.cancel')} - {!liveExistingAction && } + {!liveExistingAction && ( + + )}
diff --git a/src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx b/src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx index 5e83939a88..8a3dd021a8 100644 --- a/src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx +++ b/src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { SecurityContainmentAction } from '../utils/apiClient'; import { containmentLifecyclePresentation } from '../utils/containmentLifecycle'; +import { useI18n, useLocaleTag } from '../i18n'; +import type { MessageKey } from '../i18n'; interface ContainmentLifecycleCardProps { action: SecurityContainmentAction | null; @@ -12,15 +14,18 @@ interface ContainmentLifecycleCardProps { onResolve: () => void; } -const failureStageLabel: Record, string> = { - attach: '策略挂载', - detach: '策略解除', - reconcile: '状态恢复', +const failureStageLabel: Record< + NonNullable, + MessageKey +> = { + attach: 'cont.failureStage.attach', + detach: 'cont.failureStage.detach', + reconcile: 'cont.failureStage.reconcile', }; -function formatNs(timestampNs: number | null): string { +function formatNs(timestampNs: number | null, localeTag: string): string { if (!timestampNs) return '—'; - return new Intl.DateTimeFormat('zh-CN', { + return new Intl.DateTimeFormat(localeTag, { month: '2-digit', day: '2-digit', hour: '2-digit', @@ -29,9 +34,13 @@ function formatNs(timestampNs: number | null): string { }).format(timestampNs / 1_000_000); } -function formatRemaining(expiresAtNs: number, nowMs: number): string { +function formatRemaining( + expiresAtNs: number, + nowMs: number, + waitingLabel: string, +): string { const seconds = Math.max(0, Math.ceil(expiresAtNs / 1_000_000 - nowMs) / 1_000); - if (seconds === 0) return '等待状态刷新'; + if (seconds === 0) return waitingLabel; const total = Math.ceil(seconds); const hours = Math.floor(total / 3600); const minutes = Math.floor((total % 3600) / 60); @@ -48,6 +57,9 @@ export const ContainmentLifecycleCard: React.FC = onUpgrade, onResolve, }) => { + const { t } = useI18n(); + const localeTag = useLocaleTag(); + const [nowMs, setNowMs] = useState(Date.now()); const expiryMs = action?.expires_at_ns ? action.expires_at_ns / 1_000_000 : null; @@ -65,29 +77,37 @@ export const ContainmentLifecycleCard: React.FC = }; }, [expiryMs]); - const presentation = useMemo(() => ( - action ? containmentLifecyclePresentation(action) : null - ), [action]); + const presentation = useMemo( + () => (action ? containmentLifecyclePresentation(action) : null), + [action], + ); const mayRetry = action?.lifecycle_state === 'failed' || action?.lifecycle_state === 'expired'; return ( -
+
-

风险拦截

+

{t('cont.lifecycle.sectionTitle')}

{loading ? ( -

正在加载拦截状态...

+

+ {t('cont.lifecycle.loading')} +

) : error ? ( -

拦截状态暂时不可用,请刷新后重试。

+

+ {t('cont.lifecycle.error')} +

) : presentation ? (
- {presentation.label} + {t(presentation.labelKey)} - {presentation.detail} + {t(presentation.detailKey)}
) : ( -

待升级:当前仅审计,不阻断系统行为。

+

{t('cont.lifecycle.empty')}

)}
{canUpgrade && (!action || mayRetry) && !loading && !error && ( @@ -96,22 +116,50 @@ export const ContainmentLifecycleCard: React.FC = onClick={onUpgrade} className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700" > - {mayRetry ? '重新下发拦截' : '升级为拦截'} + {mayRetry ? t('cont.lifecycle.upgrade.retry') : t('cont.lifecycle.upgrade')} )}
{action && (
-
目标进程
PID {action.root_pid}
-
策略绑定
{action.binding_id}
-
到期时间
{action.expires_at_ns ? formatNs(action.expires_at_ns) : '持续生效'}
-
剩余时间
{action.expires_at_ns ? formatRemaining(action.expires_at_ns, nowMs) : '需手动解除'}
-
首次阻断
{formatNs(action.blocked_at_ns)}
-
失败阶段
{action.failure_stage ? failureStageLabel[action.failure_stage] : '—'}
+
+
{t('cont.lifecycle.field.targetProcess')}
+
PID {action.root_pid}
+
+
+
{t('cont.lifecycle.field.binding')}
+
{action.binding_id}
+
+
+
{t('cont.lifecycle.field.expiresAt')}
+
+ {action.expires_at_ns + ? formatNs(action.expires_at_ns, localeTag) + : t('cont.lifecycle.expires.persistent')} +
+
+
+
{t('cont.lifecycle.field.remaining')}
+
+ {action.expires_at_ns + ? formatRemaining(action.expires_at_ns, nowMs, t('cont.remaining.waitRefresh')) + : t('cont.lifecycle.remaining.persistent')} +
+
+
+
{t('cont.lifecycle.field.firstBlocked')}
+
{formatNs(action.blocked_at_ns, localeTag)}
+
+
+
{t('cont.lifecycle.field.failureStage')}
+
+ {action.failure_stage ? t(failureStageLabel[action.failure_stage]) : '—'} +
+
{action.failure_summary && (
-
失败说明
+
{t('cont.lifecycle.field.failureSummary')}
{action.failure_summary}
)} @@ -126,7 +174,7 @@ export const ContainmentLifecycleCard: React.FC = disabled={reviewing} className="rounded border border-gray-300 bg-white px-3 py-1.5 text-xs text-gray-600 disabled:opacity-40" > - 标记已处置 + {t('cont.lifecycle.markResolved')}
)} diff --git a/src/agentsight/dashboard/src/components/CopyButton.tsx b/src/agentsight/dashboard/src/components/CopyButton.tsx index 8ebdc7aaa0..dcc0c8c27e 100644 --- a/src/agentsight/dashboard/src/components/CopyButton.tsx +++ b/src/agentsight/dashboard/src/components/CopyButton.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef } from 'react'; +import { useI18n } from '../i18n'; function fallbackCopy(text: string, done: () => void) { const el = document.createElement('textarea'); @@ -25,8 +26,9 @@ export function copyText(text: string, done: () => void) { /** 复制按钮组件,点击后短暂显示「已复制」反馈 */ export const CopyButton: React.FC<{ text: string; title?: string }> = ({ text, - title = '复制完整 ID', + title, }) => { + const { t } = useI18n(); const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); const handleCopy = (e: React.MouseEvent) => { @@ -39,6 +41,7 @@ export const CopyButton: React.FC<{ text: string; title?: string }> = ({ // HTTP 环境下 clipboard API 可能不可用,使用 execCommand fallback copyText(text, done); }; + const resolvedTitle = title ?? t('common.copyFullId'); return ( ); }; diff --git a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx index 99e0b150bc..5311c72ed9 100644 --- a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx +++ b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx @@ -4,11 +4,10 @@ import { EvaluationNotReadyError, EvaluationRef, EvaluationResult, - INTERRUPTION_TYPE_CN, evaluateConversation, } from '../utils/apiClient'; import { EvaluationBadge } from './EvaluationBadge'; -import { useI18n } from '../i18n'; +import { useI18n, interruptionTypeKey } from '../i18n'; import type { MessageKey } from '../i18n'; interface EvaluationPanelProps { @@ -287,7 +286,8 @@ const FINDING_KEY: Record = { function findingLabel(value: string, t: TFunc): string { const key = FINDING_KEY[value]; if (key) return t(key); - return INTERRUPTION_TYPE_CN[value] ?? value; + const typeKey = interruptionTypeKey(value); + return typeKey ? t(typeKey) : value; } function findingMessageText(value: string): string { diff --git a/src/agentsight/dashboard/src/components/InterruptionPanel.tsx b/src/agentsight/dashboard/src/components/InterruptionPanel.tsx index 8ea3c68212..6add4e239f 100644 --- a/src/agentsight/dashboard/src/components/InterruptionPanel.tsx +++ b/src/agentsight/dashboard/src/components/InterruptionPanel.tsx @@ -13,8 +13,7 @@ import { fetchConversationInterruptions, resolveInterruption, } from '../utils/apiClient'; -import { useI18n, useLocaleTag } from '../i18n'; -import type { MessageKey } from '../i18n'; +import { useI18n, useLocaleTag, interruptionTypeKey } from '../i18n'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -25,14 +24,6 @@ const SEVERITY_DOT: Record = { low: 'bg-blue-400', }; -const TYPE_LABEL_KEY: Record = { - llm_error: 'comp.interrupt.type.llmError', - sse_truncated: 'comp.interrupt.type.sseTruncated', - agent_crash: 'comp.interrupt.type.agentCrash', - token_limit: 'comp.interrupt.type.tokenLimit', - context_overflow: 'comp.interrupt.type.contextOverflow', -}; - function formatNs(ns: number, locale: string): string { return new Date(ns / 1_000_000).toLocaleString(locale); } @@ -66,7 +57,7 @@ const InterruptionRow: React.FC = ({ event, onResolved }) => { const [resolveErr, setResolveErr] = useState(null); const dotStyle = SEVERITY_DOT[event.severity as InterruptionSeverity] ?? 'bg-gray-400'; - const typeKey = TYPE_LABEL_KEY[event.interruption_type]; + const typeKey = interruptionTypeKey(event.interruption_type); const typeLabel = typeKey ? t(typeKey) : event.interruption_type; const handleResolve = async () => { diff --git a/src/agentsight/dashboard/src/components/OptimizationSettings.tsx b/src/agentsight/dashboard/src/components/OptimizationSettings.tsx index 17a60354fe..59d8ab5240 100644 --- a/src/agentsight/dashboard/src/components/OptimizationSettings.tsx +++ b/src/agentsight/dashboard/src/components/OptimizationSettings.tsx @@ -1,21 +1,23 @@ import React, { useEffect, useState } from 'react'; import { fetchOptimizeConfig, saveOptimizeConfig } from '../utils/apiClient'; import type { OptimizeLlmConfig } from '../types/optimization'; +import { useI18n } from '../i18n'; +import type { MessageKey } from '../i18n'; -// ── Provider presets ────────────────────────────────────────────────────── +//  Provider presets  interface Provider { id: string; - name: string; + nameKey: MessageKey; icon: string; base_url: string; - models: { id: string; name: string }[]; + models: { id: string; name: string; nameKey?: MessageKey }[]; } const PROVIDERS: Provider[] = [ { id: 'dashscope', - name: '阿里云 DashScope', + nameKey: 'opt.llm.provider.dashscope', icon: '☁️', base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: [ @@ -23,27 +25,27 @@ const PROVIDERS: Provider[] = [ { id: 'qwen-max', name: 'Qwen Max' }, { id: 'qwen-turbo', name: 'Qwen Turbo' }, { id: 'qwen-long', name: 'Qwen Long' }, - { id: 'glm-5.2', name: 'GLM 5.2 (推理)' }, + { id: 'glm-5.2', name: 'GLM 5.2 (reasoning)', nameKey: 'opt.llm.model.glm52Reasoning' }, { id: 'deepseek-chat', name: 'DeepSeek Chat' }, - { id: 'deepseek-r1', name: 'DeepSeek R1 (推理)' }, + { id: 'deepseek-r1', name: 'DeepSeek R1 (reasoning)', nameKey: 'opt.llm.model.deepseekR1Reasoning' }, ], }, { id: 'openai', - name: 'OpenAI', + nameKey: 'opt.llm.provider.openai', icon: '🟢', base_url: 'https://api.openai.com/v1', models: [ { id: 'gpt-4o', name: 'GPT-4o' }, { id: 'gpt-4o-mini', name: 'GPT-4o Mini' }, - { id: 'o3', name: 'o3 (推理)' }, + { id: 'o3', name: 'o3 (reasoning)', nameKey: 'opt.llm.model.o3Reasoning' }, { id: 'o3-mini', name: 'o3 Mini' }, { id: 'gpt-4.1', name: 'GPT-4.1' }, ], }, { id: 'deepseek', - name: 'DeepSeek', + nameKey: 'opt.llm.provider.deepseek', icon: '🐋', base_url: 'https://api.deepseek.com/v1', models: [ @@ -53,18 +55,18 @@ const PROVIDERS: Provider[] = [ }, { id: 'zhipu', - name: '智谱 GLM', + nameKey: 'opt.llm.provider.zhipu', icon: '🔮', base_url: 'https://open.bigmodel.cn/api/paas/v4', models: [ { id: 'glm-4-plus', name: 'GLM-4 Plus' }, - { id: 'glm-5.2', name: 'GLM 5.2 (推理)' }, + { id: 'glm-5.2', name: 'GLM 5.2 (reasoning)', nameKey: 'opt.llm.model.glm52Reasoning' }, { id: 'glm-4-flash', name: 'GLM-4 Flash' }, ], }, { id: 'moonshot', - name: 'Moonshot 月之暗面', + nameKey: 'opt.llm.provider.moonshot', icon: '🌙', base_url: 'https://api.moonshot.cn/v1', models: [ @@ -74,14 +76,14 @@ const PROVIDERS: Provider[] = [ }, { id: 'custom', - name: '自定义端点', + nameKey: 'opt.llm.provider.custom', icon: '⚙️', base_url: '', models: [], }, ]; -// ── Detect provider from base_url ───────────────────────────────────────── +//  Detect provider from base_url  function detectProvider(baseUrl: string): string { for (const p of PROVIDERS) { @@ -93,15 +95,16 @@ function detectProvider(baseUrl: string): string { return 'custom'; } -// ── Shared input styles ─────────────────────────────────────────────────── +//  Shared input styles  const inputCls = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400'; -// ── Component ───────────────────────────────────────────────────────────── +//  Component  /** LLM configuration form for the optimization analysis feature (rendered in the settings page). */ export const LlmConfigForm: React.FC = () => { + const { t } = useI18n(); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -117,7 +120,7 @@ export const LlmConfigForm: React.FC = () => { const [isKnownModel, setIsKnownModel] = useState(true); const [showKey, setShowKey] = useState(false); - const activeProvider = PROVIDERS.find(p => p.id === provider) ?? PROVIDERS[PROVIDERS.length - 1]; + const activeProvider = PROVIDERS.find((p) => p.id === provider) ?? PROVIDERS[PROVIDERS.length - 1]; const availableModels = activeProvider.models; useEffect(() => { @@ -131,24 +134,25 @@ export const LlmConfigForm: React.FC = () => { // Auto-detect provider const pid = detectProvider(data.base_url); setProvider(pid); - const p = PROVIDERS.find(x => x.id === pid); - if (p && !p.models.some(m => m.id === data.model)) { + const p = PROVIDERS.find((x) => x.id === pid); + if (p && !p.models.some((m) => m.id === data.model)) { setIsKnownModel(false); setCustomModel(data.model); } setError(null); } catch (e) { - setError(`加载配置失败: ${e instanceof Error ? e.message : String(e)}`); + const msg = e instanceof Error ? e.message : String(e); + setError(t('opt.llm.loadFailed', { msg })); } finally { setLoading(false); } })(); - }, []); + }, [t]); // When provider changes, update base URL function handleProviderChange(id: string) { setProvider(id); - const p = PROVIDERS.find(x => x.id === id); + const p = PROVIDERS.find((x) => x.id === id); if (p && p.id !== 'custom') { setBaseUrl(p.base_url); } @@ -157,7 +161,7 @@ export const LlmConfigForm: React.FC = () => { // When provider changes, check if current model is in the list useEffect(() => { if (!model) return; - const found = availableModels.some(m => m.id === model); + const found = availableModels.some((m) => m.id === model); if (found) { setIsKnownModel(true); setCustomModel(''); @@ -192,7 +196,8 @@ export const LlmConfigForm: React.FC = () => { setSaved(true); setTimeout(() => setSaved(false), 3000); } catch (e2) { - setError(`保存失败: ${e2 instanceof Error ? e2.message : String(e2)}`); + const msg = e2 instanceof Error ? e2.message : String(e2); + setError(t('opt.llm.saveFailed', { msg })); } finally { setSaving(false); } @@ -203,9 +208,9 @@ export const LlmConfigForm: React.FC = () => { {/* Header */}
-

LLM 配置

+

{t('opt.llm.title')}

- 选择云服务厂商和模型,修改后立即生效,无需重启。用于优化分析(性能策略 / 成本浪费 / 准确性维度)。 + {t('opt.llm.subtitle')}

@@ -213,21 +218,23 @@ export const LlmConfigForm: React.FC = () => { {loading ? (
- 加载配置中... + {t('opt.llm.loading')}
) : (
{/* Provider */}
- + @@ -261,7 +268,7 @@ export const LlmConfigForm: React.FC = () => { className={inputCls} value={apiKey} onChange={(e) => setApiKey(e.target.value)} - placeholder={config?.api_key ?? '输入 API Key'} + placeholder={config?.api_key ?? t('opt.llm.apiKey.placeholder')} spellCheck={false} autoComplete="off" /> @@ -269,19 +276,21 @@ export const LlmConfigForm: React.FC = () => { type="button" className="flex-shrink-0 px-2.5 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors" onClick={() => setShowKey(!showKey)} - title={showKey ? '隐藏' : '显示'} + title={showKey ? t('opt.llm.apiKey.hide') : t('opt.llm.apiKey.show')} > {showKey ? '🙈' : '👁'}

- {config?.api_key ? '留空则保持当前配置不变' : '在对应厂商控制台获取 API Key'} + {config?.api_key ? t('opt.llm.apiKey.keepUnchanged') : t('opt.llm.apiKey.hint')}

{/* Model */}
- + {provider !== 'custom' && availableModels.length > 0 ? ( <> {!isKnownModel && ( { className={`${inputCls} mt-1.5`} value={customModel} onChange={(e) => setCustomModel(e.target.value)} - placeholder="输入模型 ID" + placeholder={t('opt.llm.model.customPlaceholder')} spellCheck={false} /> )} @@ -318,7 +329,7 @@ export const LlmConfigForm: React.FC = () => { className={inputCls} value={model} onChange={(e) => setModel(e.target.value)} - placeholder="输入模型 ID,如 gpt-4o" + placeholder={t('opt.llm.model.placeholder')} spellCheck={false} /> )} @@ -337,10 +348,10 @@ export const LlmConfigForm: React.FC = () => { disabled={saving} className="px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors disabled:opacity-50" > - {saving ? '保存中...' : '保存配置'} + {saving ? t('opt.llm.saving') : t('opt.llm.save')} {saved && ( - ✓ 配置已保存并生效 + {t('opt.llm.saved')} )}
@@ -348,3 +359,4 @@ export const LlmConfigForm: React.FC = () => {
); }; + diff --git a/src/agentsight/dashboard/src/components/TokenFlameChart.tsx b/src/agentsight/dashboard/src/components/TokenFlameChart.tsx index 8655e0bb98..2f1fa0d9b8 100644 --- a/src/agentsight/dashboard/src/components/TokenFlameChart.tsx +++ b/src/agentsight/dashboard/src/components/TokenFlameChart.tsx @@ -1,20 +1,22 @@ import React, { useMemo, useRef, useState, useEffect } from 'react'; import type { CostStats, LLMCall, WasteItem, WasteReport } from '../types/optimization'; import { copyText } from './CopyButton'; +import { useI18n } from '../i18n'; +import type { MessageKey } from '../i18n'; type DimState = 'idle' | 'loading' | 'done' | 'error'; // ── 类别定义(与后端 LlmCall 字段一一对应)──────────────────────────────────── // 上柱:Context Window 组成(5 类,静态区域合并为一类) -type Cat = { key: string; label: string; color: string; get: (c: LLMCall) => number }; +type Cat = { key: string; labelKey: MessageKey; color: string; get: (c: LLMCall) => number }; // 配色:agentsight 亮色主题(Tailwind 系)——冷(人类输入/静态)在下,暖(重放历史)在上。 const CONTEXT_CATS: Cat[] = [ - { key: 'static', label: '静态区域', color: '#6b7280', get: c => c.system_prompt + c.skill_definitions + c.tool_definitions }, // gray-500 · 恒定基岩 - { key: 'user', label: '用户提示词', color: '#3b82f6', get: c => c.user_messages }, // primary 蓝 · 人类输入 - { key: 'assistant', label: '助手输出', color: '#f59e0b', get: c => c.assistant_messages }, // amber-500 · O(n) 累积 - { key: 'tool', label: '工具输出', color: '#10b981', get: c => c.tool_results }, // emerald-500 · 沉积大头 - { key: 'others', label: '其它', color: '#8b5cf6', get: c => c.injected_context }, // violet-500 · 注入 + { key: 'static', labelKey: 'flame.ctx.static', color: '#6b7280', get: c => c.system_prompt + c.skill_definitions + c.tool_definitions }, // gray-500 · 恒定基岩 + { key: 'user', labelKey: 'flame.ctx.user', color: '#3b82f6', get: c => c.user_messages }, // primary 蓝 · 人类输入 + { key: 'assistant', labelKey: 'flame.ctx.assistant', color: '#f59e0b', get: c => c.assistant_messages }, // amber-500 · O(n) 累积 + { key: 'tool', labelKey: 'flame.ctx.tool', color: '#10b981', get: c => c.tool_results }, // emerald-500 · 沉积大头 + { key: 'others', labelKey: 'flame.ctx.others', color: '#8b5cf6', get: c => c.injected_context }, // violet-500 · 注入 ]; const OUTPUT_COLOR = '#ef4444'; // 输出曲线(danger 红) @@ -57,6 +59,7 @@ const Spinner: React.FC<{ size?: number }> = ({ size = 18 }) => ( export default function TokenFlameChart({ cost, waste, wasteState }: { cost: CostStats; waste?: WasteReport | null; wasteState?: DimState }) { const calls = cost.calls ?? []; const [selected, setSelected] = useState(() => Math.max(0, calls.length - 1)); + const { t } = useI18n(); // 派生聚合(全部 hooks 必须在早退之前调用) const derived = useMemo(() => { @@ -102,10 +105,8 @@ export default function TokenFlameChart({ cost, waste, wasteState }: { cost: Cos if (calls.length === 0) { return (
-

Token 火焰图

-

- 该会话无逐步(per-step)成本数据 —— 可能是旧会话或轨迹解析失败。重新分析即可生成火焰图。 -

+

{t('flame.title')}

+

{t('flame.empty.noPerStepData')}

); } @@ -131,14 +132,14 @@ export default function TokenFlameChart({ cost, waste, wasteState }: { cost: Cos {/* 1 · Summary Cards(「LLM 判定可省」卡已删:汇总数字为伪精度,加载/失败状态由下方浪费诊断表承担) */}
in {fmtK(derived.inputTokens)} · out {fmtK(derived.outputTokens)}} + sub={t('flame.summary.totalTokensSub', { input: fmtK(derived.inputTokens), output: fmtK(derived.outputTokens) })} /> setSelected(derived.peakIdx)} /> @@ -149,17 +150,17 @@ export default function TokenFlameChart({ cost, waste, wasteState }: { cost: Cos

- Context Window 组成 · 逐步重放 · 点击柱体查看该步 + {t('flame.chart.title')} · {t('flame.chart.subtitle')}

{/* 图例 */}
{visCtx.map(cat => ( - {cat.label} + {t(cat.labelKey)} ))} - 输出 token + {t('flame.legend.outputTokens')}
@@ -168,7 +169,7 @@ export default function TokenFlameChart({ cost, waste, wasteState }: { cost: Cos {/* 固定左栏:标注上区语义(不随横向滚动移动) */}
- 已发送 · context + {t('flame.axis.contextLabel')}
{/* 固定 y 轴刻度(不随横向滚动移动,否则向右滚时刻度不可见) */} @@ -257,67 +258,68 @@ export default function TokenFlameChart({ cost, waste, wasteState }: { cost: Cos // ── 浪费诊断表 ────────────────────────────────────────────────────────────────── function wasteColor(optimization: string): string { const o = optimization; - if (o.includes('缓存') || o.includes('Cache')) return '#6366f1'; - if (o.includes('历史') || o.includes('History')) return '#14b8a6'; - if (o.includes('工具输出') || o.includes('Trim')) return '#eab308'; - if (o.includes('注入') || o.includes('Context')) return '#fb923c'; - if (o.includes('提示词压缩') || o.includes('Compression')) return '#3b82f6'; - if (o.includes('经验')) return '#8b5cf6'; // 试错型 · violet-500 - if (o.includes('规范')) return '#0ea5e9'; // 返工型 · sky-500 + if (o.includes('\u7f13\u5b58') || o.includes('Cache')) return '#6366f1'; + if (o.includes('\u5386\u53f2') || o.includes('History')) return '#14b8a6'; + if (o.includes('\u5de5\u5177\u8f93\u51fa') || o.includes('Trim')) return '#eab308'; + if (o.includes('\u6ce8\u5165') || o.includes('Context')) return '#fb923c'; + if (o.includes('\u63d0\u793a\u8bcd\u538b\u7f29') || o.includes('Compression')) return '#3b82f6'; + if (o.includes('\u7ecf\u9a8c')) return '#8b5cf6'; // 试错型 · violet-500 + if (o.includes('\u89c4\u8303')) return '#0ea5e9'; // 返工型 · sky-500 return ROSE; } // 置信度 badge 配色(与准确性 IssueTable 一致) const CONF_CLS: Record = { - 高: 'bg-green-100 text-green-700', - 中: 'bg-yellow-100 text-yellow-700', - 低: 'bg-gray-100 text-gray-500', + '\u9ad8': 'bg-green-100 text-green-700', + '\u4e2d': 'bg-yellow-100 text-yellow-700', + '\u4f4e': 'bg-gray-100 text-gray-500', }; /** 经验条目的可复制文本 —— 直接贴进 Skill / 规范文件。 */ -function experienceText(it: WasteItem): string { +function experienceText(t: (key: MessageKey, params?: Record) => string, it: WasteItem): string { const e = it.experience; if (!e) return ''; const lines: string[] = []; - if (e.applicability) lines.push(`适用场景:${e.applicability}`); - if (e.pitfall) lines.push(`错误做法:${e.pitfall}`); - if (e.effective_path) lines.push(`正确做法:${e.effective_path}`); - if (e.rule) lines.push(`约定:${e.rule}`); - if (e.bad_example) lines.push(`反例:${e.bad_example}`); - if (e.good_example) lines.push(`正例:${e.good_example}`); - if (e.scope) lines.push(`适用范围:${e.scope}`); + if (e.applicability) lines.push(`${t('flame.exp.applicability')}${e.applicability}`); + if (e.pitfall) lines.push(`${t('flame.exp.pitfall')}${e.pitfall}`); + if (e.effective_path) lines.push(`${t('flame.exp.effectivePath')}${e.effective_path}`); + if (e.rule) lines.push(`${t('flame.exp.rule')}${e.rule}`); + if (e.bad_example) lines.push(`${t('flame.exp.badExample')}${e.bad_example}`); + if (e.good_example) lines.push(`${t('flame.exp.goodExample')}${e.good_example}`); + if (e.scope) lines.push(`${t('flame.exp.scope')}${e.scope}`); return lines.join('\n'); } /** 优化提示词 —— 有经验用经验全文;token 浪费类无 experience 字段,由现象/手段/证据合成。 */ -function promptText(it: WasteItem): string { - const exp = experienceText(it); +function promptText(t: (key: MessageKey, params?: Record) => string, it: WasteItem): string { + const exp = experienceText(t, it); if (exp) return exp; - const lines = [`现象:${it.symptom}`, `优化手段:${it.optimization}`]; - if (it.evidence) lines.push(`证据:${it.evidence}`); + const lines = [`${t('flame.exp.symptom')}${it.symptom}`, `${t('flame.exp.optimization')}${it.optimization}`]; + if (it.evidence) lines.push(`${t('flame.exp.evidence')}${it.evidence}`); return lines.join('\n'); } /** 展开详情行:完整证据 + 可沉淀经验(结构与准确性表的展开区一致)。 */ function WasteDetailRow({ it }: { it: WasteItem }) { - const text = experienceText(it); + const { t } = useI18n(); + const text = experienceText(t, it); return (
时间CategoryResultVerdictSessionRunTool Call{t('common.time')}{t('sec.category')}{t('sec.result')}{t('sec.verdict')}{t('sec.session')}{t('sec.run')}{t('sec.toolCall')}
-
证据
+
{t('flame.detail.evidence')}
{it.evidence}
{text && (
-
经验 · 可直接贴进 Skill / 规范
+
{t('flame.detail.experience')}
{text}
{it.steps && it.steps.length > 0 && (
- 共 {it.steps.length} 轮 · 单条轨迹证据,跨会话复现后可提升置信度 + {t('flame.detail.stepsNote', { steps: it.steps.length })}
)}
@@ -332,6 +334,7 @@ function WasteDetailRow({ it }: { it: WasteItem }) { // 优化提示词复制按钮(与准确性表同款) function ExpCopyBtn({ text }: { text: string }) { const [done, setDone] = useState(false); + const { t } = useI18n(); // HTTP 非安全上下文无 navigator.clipboard,copyText 内部自动降级 const handleCopy = (e: React.MouseEvent) => { e.stopPropagation(); @@ -348,29 +351,30 @@ function ExpCopyBtn({ text }: { text: string }) { ? 'bg-green-100 text-green-600' : 'bg-gray-100 hover:bg-gray-200 text-gray-500 hover:text-gray-700' }`} - title={done ? '已复制' : '复制优化提示词'} + title={done ? t('common.copied') : t('flame.copyPromptTitle')} > - {done ? '✓ 已复制' : '⧉ 复制'} + {done ? t('common.copied') : `⧉ ${t('common.copy')}`} ); } function WasteTable({ waste, wasteState }: { waste?: WasteReport | null; wasteState?: DimState }) { const [open, setOpen] = useState(0); + const { t } = useI18n(); // 加载中 / 失败 / 未分析态 if (wasteState !== 'done' || !waste) { return (
-

浪费剖析

+

{t('flame.waste.title')}

{wasteState === 'error' ? ( -

识别失败 —— LLM 调用出错,可稍后重试。

+

{t('flame.waste.error')}

) : wasteState === 'loading' ? (
- LLM 正在逐条判定候选是否值得优化… + {t('flame.waste.loading')}
) : ( -

尚未分析 —— 点击「重新分析」运行 LLM 浪费判定。

+

{t('flame.waste.notAnalyzed')}

)}
); @@ -379,14 +383,12 @@ function WasteTable({ waste, wasteState }: { waste?: WasteReport | null; wasteSt if (waste.items.length === 0) { return (
-

浪费剖析

+

{t('flame.waste.title')}

{waste.considered === 0 ? ( -

- 无可评估的浪费候选 —— 轨迹过短或采集内容不足,未做判定(见上方提示)。 -

+

{t('flame.waste.noCandidates')}

) : (

- ✓ LLM 评估了 {waste.considered} 项候选,未发现值得优化的浪费。 + {t('flame.waste.noWasteFound', { count: waste.considered })}

)}
@@ -395,22 +397,22 @@ function WasteTable({ waste, wasteState }: { waste?: WasteReport | null; wasteSt return (
-

浪费剖析

+

{t('flame.waste.title')}

- - - - - + + + + + {waste.items.map((it, i) => { const isOpen = open === i; - const expText = promptText(it); + const expText = promptText(t, it); return (
现象浪费类型优化手段置信度优化提示词{t('flame.waste.col.symptom')}{t('flame.waste.col.type')}{t('flame.waste.col.optimization')}{t('flame.waste.col.confidence')}{t('flame.waste.col.prompt')}
{it.optimization} - - {it.confidence} - + {(() => { + const raw = it.confidence; + const key = + raw === '\u9ad8' + ? 'flame.confidence.high' + : raw === '\u4e2d' + ? 'flame.confidence.medium' + : raw === '\u4f4e' + ? 'flame.confidence.low' + : null; + const label = key ? t(key as MessageKey) : raw; + const cls = CONF_CLS[raw] ?? CONF_CLS['\u4f4e']; + return ( + + {label} + + ); + })()} @@ -452,12 +469,13 @@ function WasteTable({ waste, wasteState }: { waste?: WasteReport | null; wasteSt function DetailPanel({ c, visCtx }: { c: LLMCall; visCtx: Cat[]; }) { + const { t } = useI18n(); const total = ctxTotal(c); return (
-

Step #{c.step_id}

+

{t('flame.detail.stepTitle', { id: c.step_id })}

{c.time} · {c.label}
@@ -469,14 +487,14 @@ function DetailPanel({ c, visCtx }: { return (
- {cat.label} + {t(cat.labelKey)} {fmtK(v)} · {pct.toFixed(0)}%
); })}
- 本轮输出 + {t('flame.detail.currentOutput')} ↑{fmtK(c.output_tokens)}
diff --git a/src/agentsight/dashboard/src/i18n.tsx b/src/agentsight/dashboard/src/i18n.tsx index 424f24c79f..2d065c89a0 100644 --- a/src/agentsight/dashboard/src/i18n.tsx +++ b/src/agentsight/dashboard/src/i18n.tsx @@ -453,11 +453,27 @@ const enUSMessages = { 'comp.interrupt.noEvents': 'No interruption events recorded for this session.', 'comp.interrupt.failedToLoad': 'Failed to load interruptions', 'comp.interrupt.callLabel': 'call: {id}', - 'comp.interrupt.type.llmError': 'LLM Error', - 'comp.interrupt.type.sseTruncated': 'SSE Truncated', - 'comp.interrupt.type.agentCrash': 'Agent Crash', - 'comp.interrupt.type.tokenLimit': 'Token Limit', - 'comp.interrupt.type.contextOverflow': 'Context Overflow', + 'comp.interrupt.typeCount': '{type}: {count}', + + // ── Interruption types (shared across pages) ── + 'itype.llm_error': 'LLM Error', + 'itype.sse_truncated': 'SSE Truncated', + 'itype.context_overflow': 'Context Overflow', + 'itype.agent_crash': 'Agent Crash', + 'itype.token_limit': 'Token Limit', + 'itype.rate_limit': 'Rate Limit', + 'itype.auth_error': 'Auth Error', + 'itype.network_timeout': 'Network Timeout', + 'itype.service_unavailable': 'Service Unavailable', + 'itype.safety_filter': 'Safety Filter', + 'itype.retry_storm': 'Retry Storm', + 'itype.dead_loop': 'Dead Loop', + 'itype.tool_failure': 'Tool Failure', + 'itype.empty_response': 'Empty Response', + 'itype.resource_exhaustion': 'Resource Exhaustion', + 'itype.slow_response': 'Slow Response', + 'itype.state_machine_error': 'State Machine Error', + 'itype.unauthorized_action': 'Unauthorized Action', // ── EvaluationPanel ── 'comp.eval.noResult': 'No evaluation result yet.', @@ -511,6 +527,468 @@ const enUSMessages = { 'comp.eval.finding.network_timeout': 'Network timeout', 'comp.eval.finding.service_unavailable': 'Service unavailable', 'comp.eval.finding.agent_crash': 'Agent crash', + + // ── OptimizationPage / OptimizationSettings ── + 'opt.error.requestFailedStatus': 'Request failed ({status})', + 'opt.loading.analyzingTag': 'Analyzing', + 'opt.loading.calculating': 'Calculating, please wait...', + 'opt.error.analysisFailedHint': 'Analysis failed — you can click "Re-analyze" later to retry.', + 'opt.dim.notAnalyzedHint': 'This dimension has not been analyzed yet — click "Start analysis" in the top-right corner.', + 'opt.summary.generating': 'Generating trajectory summary…', + 'opt.summary.title': 'Trajectory Summary', + 'opt.summary.goalLabel': 'Goal', + 'opt.summary.processLabel': 'Process', + 'opt.summary.outcomeLabel': 'Outcome', + 'opt.copy.promptTitle': 'Copy optimization prompt', + 'opt.failure.toolErrorLabel': 'Tool error', + 'opt.failure.reasoningErrorLabel': 'Reasoning error', + 'opt.failure.timeoutLabel': 'Timeout', + 'opt.failure.invalidUsageLabel': 'Invalid usage', + 'opt.accuracy.failureRecovery': 'Recovery: {text}', + 'opt.accuracy.table.symptom': 'Symptom', + 'opt.accuracy.table.defectType': 'Defect type', + 'opt.accuracy.table.rootCause': 'Root cause object', + 'opt.accuracy.table.fixLocus': 'Fix locus', + 'opt.accuracy.table.confidence': 'Confidence', + 'opt.accuracy.table.prompt': 'Optimization prompt', + 'opt.accuracy.recoveredBadge': 'Recovered · optimization clue', + 'opt.accuracy.notOptimizable': 'Not optimizable', + 'opt.accuracy.detail.evidence': 'Evidence', + 'opt.accuracy.detail.verify': 'Verification', + 'opt.accuracy.detail.fix': 'Fix', + 'opt.accuracy.fixDiverges': 'Root cause at {primary}, fix at {fixLocus}', + 'opt.accuracy.fixLocusNone': 'None', + 'opt.accuracy.fixSame': 'Same as primary root cause', + 'opt.accuracy.sectionTitle': 'Accuracy analysis', + 'opt.accuracy.issueCountTag': '{count} issues', + 'opt.accuracy.noIssues': '✓ No issues detected that affect the final output accuracy.', + 'opt.accuracy.issueTableTitle': 'Failure list · click any row to expand root cause and fix', + 'opt.accuracy.issueTableTitleFallback': 'Failure list', + 'opt.accuracy.analyzeFailed': 'Accuracy analysis failed: {msg}', + 'opt.perf.state.error': 'Identification failed — LLM call error, please retry later.', + 'opt.perf.state.loading': 'LLM is analyzing performance data and choosing optimization strategies…', + 'opt.perf.state.idle': 'Not analyzed yet — click "Re-analyze" to run LLM strategy selection.', + 'opt.perf.state.noStrategies': '✓ LLM analysis completed, no applicable optimization strategies were found.', + 'opt.perf.table.symptom': 'Symptom', + 'opt.perf.table.strategyType': 'Strategy type', + 'opt.perf.table.rootCause': 'Root cause', + 'opt.perf.table.optimization': 'Optimization strategy', + 'opt.perf.sectionTitle': 'Performance analysis', + 'opt.perf.timeDistributionTitle': 'Time distribution', + 'opt.perf.slice.model': 'Model inference', + 'opt.perf.slice.tools': 'Tool execution', + 'opt.perf.slice.idle': 'User idle', + 'opt.perf.totalToolCalls': '{n} tool calls', + 'opt.perf.slowestCallsTitle': 'Slowest calls', + 'opt.perf.table.toolName': 'Tool', + 'opt.perf.table.duration': 'Duration', + 'opt.perf.table.command': 'Command', + 'opt.perf.noToolCalls': 'This session has no tool calls; all latency comes from model inference.', + 'opt.cost.sectionTitle': 'Cost analysis', + 'opt.cost.tag.headroomMeasured': '{tokens} tok · Headroom measured -{pct}%', + 'opt.cost.tag.headroom': '{tokens} tok', + 'opt.cost.tag.noHeadroom': '{events} events · {chars} characters', + 'opt.cost.meta': '{chars} characters · {events} events · {calls} LLM calls · {source}', + 'opt.cost.tokSourceMeasuredUsage': 'token measured (usage)', + 'opt.cost.tokSourceMeasuredSteps': 'token measured {used}/{total} steps', + 'opt.cost.tokSourceEstimated': 'token estimated', + 'opt.tab.accuracy.name': 'Accuracy', + 'opt.tab.accuracy.label': 'Accuracy analysis', + 'opt.tab.perf.name': 'Performance', + 'opt.tab.perf.label': 'Performance analysis', + 'opt.tab.cost.name': 'Cost', + 'opt.tab.cost.label': 'Cost analysis', + 'opt.tabs.ariaLabel': 'Dimension analysis', + 'opt.session.backToList': '← Back to session list', + 'opt.session.headerTitle': 'Optimization analysis · Session', + 'opt.session.viewSourceTrajectory': '🔍 View analyzed trajectory ↗', + 'opt.session.viewAnalysisTrajectory': '🤖 View analysis trajectory (agentsight-opt) ↗', + 'opt.session.action.analyzing': 'Analyzing...', + 'opt.session.action.reanalyze': 'Re-analyze', + 'opt.session.action.start': 'Start analysis', + 'opt.session.loadingHistory': 'Loading historical analysis results...', + 'opt.session.noAnalysisYet': 'This session has not been analyzed yet.', + 'opt.session.startHint': 'Click "Start analysis" to run accuracy / performance / cost analysis in parallel.', + 'opt.llm.notConfiguredHint': 'LLM is not configured — summary / performance strategies / cost waste / accuracy analysis all require LLM calls.', + 'opt.llm.goToSettings': 'Go to settings', + 'opt.summaryRow.issueCount': '{count} issues', + 'opt.summaryRow.accuracyLoading': 'Accuracy analysis in progress...', + 'opt.summaryRow.perf': '· {toolCount} tool calls · {seconds}s', + 'opt.summaryRow.cost': '· {events} events', + 'opt.dim.summary': 'Summary', + 'opt.dim.perf': 'Performance', + 'opt.dim.perfStrategy': 'Performance strategy', + 'opt.dim.cost': 'Cost', + 'opt.dim.costWaste': 'Cost waste', + 'opt.dim.accuracy': 'Accuracy', + 'opt.entry.subtitle': 'Enter a session ID to run accuracy / performance / cost analysis.', + 'opt.entry.sessionIdLabel': 'Session ID', + 'opt.entry.sessionIdPlaceholder': 'Paste a session ID and press Enter to start analysis', + 'opt.history.title': 'Historical analyses', + 'opt.history.count': '{n} records in total', + 'opt.history.limit': '(only the most recent {n} records are shown)', + 'opt.history.loadFailed': 'Failed to load history: {msg}', + 'opt.history.loading': 'Loading historical analyses...', + 'opt.history.empty': 'No analysis records yet', + 'opt.history.col.sessionId': 'Session ID', + 'opt.history.col.dimensions': 'Analyzed dimensions', + 'opt.history.col.firstAnalyzed': 'First analyzed at', + 'opt.history.col.lastUpdated': 'Last updated at', + 'opt.history.paginationSummary': '{count} results · page {page}/{total}', + 'opt.llm.provider.dashscope': 'Alibaba Cloud DashScope', + 'opt.llm.provider.openai': 'OpenAI', + 'opt.llm.provider.deepseek': 'DeepSeek', + 'opt.llm.provider.zhipu': 'Zhipu GLM', + 'opt.llm.provider.moonshot': 'Moonshot AI', + 'opt.llm.provider.custom': 'Custom endpoint', + 'opt.llm.model.glm52Reasoning': 'GLM 5.2 (reasoning)', + 'opt.llm.model.deepseekR1Reasoning': 'DeepSeek R1 (reasoning)', + 'opt.llm.model.o3Reasoning': 'o3 (reasoning)', + 'opt.llm.loadFailed': 'Failed to load configuration: {msg}', + 'opt.llm.saveFailed': 'Failed to save configuration: {msg}', + 'opt.llm.title': 'LLM configuration', + 'opt.llm.subtitle': 'Choose cloud provider and model; changes take effect immediately without restart. Used for optimization analysis (performance strategies / cost waste / accuracy).', + 'opt.llm.loading': 'Loading configuration...', + 'opt.llm.providerLabel': 'Cloud provider', + 'opt.llm.apiKey.placeholder': 'Enter API key', + 'opt.llm.apiKey.hide': 'Hide', + 'opt.llm.apiKey.show': 'Show', + 'opt.llm.apiKey.keepUnchanged': 'Leave empty to keep the current key unchanged.', + 'opt.llm.apiKey.hint': "Obtain the API key from the provider's console.", + 'opt.llm.modelLabel': 'Model', + 'opt.llm.model.customOption': '✏️ Custom model name...', + 'opt.llm.model.customPlaceholder': 'Enter model ID', + 'opt.llm.model.placeholder': 'Enter model ID, e.g. gpt-4o', + 'opt.llm.saving': 'Saving...', + 'opt.llm.save': 'Save configuration', + 'opt.llm.saved': '✓ Configuration saved and applied', + + // ── TokenFlameChart ── + 'flame.ctx.static': 'Static region', + 'flame.ctx.user': 'User prompts', + 'flame.ctx.assistant': 'Assistant messages', + 'flame.ctx.tool': 'Tool outputs', + 'flame.ctx.others': 'Other injected context', + 'flame.title': 'Token Flame Chart', + 'flame.empty.noPerStepData': 'This session has no per-step cost data — it may be an older session or a failed trajectory parse. Re-run the analysis to generate the flame chart.', + 'flame.summary.totalTokens': 'Total Tokens', + 'flame.summary.totalTokensSub': 'in {input} · out {output}', + 'flame.summary.peakContext': 'Peak Context', + 'flame.summary.peakContextSub': 'step {step} · {time} · click to locate', + 'flame.chart.title': 'Context Window composition · per-step replay', + 'flame.chart.subtitle': 'Click a bar to view that step', + 'flame.legend.outputTokens': 'Output tokens', + 'flame.axis.contextLabel': 'Sent · context', + 'flame.waste.title': 'Waste Analysis', + 'flame.waste.error': 'Identification failed — the LLM call errored, please retry later.', + 'flame.waste.loading': 'The LLM is evaluating each candidate to see if it is worth optimizing…', + 'flame.waste.notAnalyzed': 'Not analyzed yet — click "Re-analyze" to run the LLM waste detection.', + 'flame.waste.noCandidates': 'No waste candidates to evaluate — the trajectory is too short or data insufficient; no evaluation was performed.', + 'flame.waste.noWasteFound': '✓ The LLM evaluated {count} candidates and found no waste worth optimizing.', + 'flame.waste.col.symptom': 'Symptom', + 'flame.waste.col.type': 'Waste type', + 'flame.waste.col.optimization': 'Optimization', + 'flame.waste.col.confidence': 'Confidence', + 'flame.waste.col.prompt': 'Optimization prompt', + 'flame.confidence.high': 'High', + 'flame.confidence.medium': 'Medium', + 'flame.confidence.low': 'Low', + 'flame.detail.evidence': 'Evidence', + 'flame.detail.experience': 'Experience · can be pasted directly into Skill/spec', + 'flame.detail.stepsNote': '{steps} rounds · single-trajectory evidence; reproducing across sessions will increase confidence', + 'flame.exp.applicability': 'Scenario: ', + 'flame.exp.pitfall': 'Wrong approach: ', + 'flame.exp.effectivePath': 'Correct approach: ', + 'flame.exp.rule': 'Convention: ', + 'flame.exp.badExample': 'Anti-pattern: ', + 'flame.exp.goodExample': 'Example: ', + 'flame.exp.scope': 'Scope: ', + 'flame.exp.symptom': 'Symptom: ', + 'flame.exp.optimization': 'Optimization: ', + 'flame.exp.evidence': 'Evidence: ', + 'flame.copyPromptTitle': 'Copy optimization prompt', + 'flame.detail.stepTitle': 'Step #{id}', + 'flame.detail.currentOutput': 'This step output', + + // ── AgentSessionsPage ── + 'as.daysAgo': '{n} days ago', + 'as.timePreset.last24h': 'Last 24h', + 'as.timePreset.last7d': 'Last 7d', + 'as.timePreset.last30d': 'Last 30d', + 'as.source.ebpf': 'eBPF capture', + 'as.source.log': 'Log collection', + 'as.fetchError': 'Failed to fetch session list', + 'as.totalSessions': 'Total {n} sessions', + 'as.autoRefresh': 'Auto-refresh', + 'as.filter.allSources': 'All sources', + 'as.searchPlaceholder': 'Search session ID / project / message content...', + 'as.loadingSessions': 'Loading session list...', + 'as.noMatchingSessions': 'No matching sessions', + 'as.noSessionsInRange': 'No session data in the current time range', + 'as.source': 'Source', + 'as.project': 'Project', + 'as.firstMessage': 'First message', + 'as.lastMessage': 'Latest message', + 'as.lastActive': 'Last active', + 'as.copySessionId': 'Copy session ID', + 'as.subagentBadgeTitle': 'This session spawned {n} subagents', + 'as.subagentCount': '{n} subagents', + 'as.openInNewWindow': 'Click to view trajectory details in a new window', + 'as.metricsTooltip': 'Messages/steps {count} · Tokens {inTokens} / {outTokens}', + 'as.runOptimizationTitle': 'Run optimization analysis for this session', + 'as.analyze': '🔬 Analyze', + 'as.paginationSummary': '{total} results · Page {cur}/{totalPages}', + + // ── Small shared components / SettingsPage ── + 'comp.agentHealth.crashToast': '⚠️ Agent "{name}" (PID {pid}) crashed and affected an in-flight conversation', + 'comp.agentHealth.hungToast': '⏳ Agent "{name}" (PID {pid}) timed out responding and may be hung', + 'comp.settings.title': '⚙️ Settings', + 'comp.settings.description': 'Manage the dashboard global configuration.', + + // ── RiskEnforcementPage ── + 'risk.error.requestFailed': 'Request failed', + 'risk.mode.observe': 'Observe', + 'risk.mode.audit': 'Audit', + 'risk.mode.enforce': 'Enforce', + 'risk.bindingState.pending': 'Pending deployment', + 'risk.bindingState.enforced': 'Enforcing', + 'risk.bindingState.failed': 'Failed', + 'risk.bindingState.degraded': 'Degraded', + 'risk.bindingState.detaching': 'Detaching', + 'risk.bindingState.detached': 'Detached', + 'risk.effect.notify': 'Logged', + 'risk.effect.block': 'Blocked', + 'risk.effect.kill': 'Terminated', + 'risk.operation.invalidPid': 'PID must be a positive integer', + 'risk.operation.createSucceeded': 'Policy applied successfully', + 'risk.operation.detachSucceeded': 'Policy detached successfully', + 'risk.detach.confirm': 'Are you sure you want to detach this risk enforcement policy?', + 'risk.readiness.ready': 'Running', + 'risk.readiness.unavailable': 'Unavailable', + 'risk.readiness.checking': 'Checking', + 'risk.capability.loading': 'Reading enforcer capabilities and readiness...', + 'risk.capability.testDev': 'Test / development enforcer: includes simulated enforcement and policy handoff', + 'risk.capability.prod': 'Production enforcer: supports observe/audit only; credential enforcement and policy handoff are not available', + 'risk.title.enforce': 'Risk Enforcement', + 'risk.title.observeAudit': 'Risk Observation & Audit', + 'risk.subtitle': 'Use sensitive files as data sources, propagate taint to outgoing network traffic, and apply policies progressively (observe → audit → enforce).', + 'risk.refresh.loading': 'Refreshing...', + 'risk.summary.enforcerStatus': 'Enforcer status', + 'risk.summary.backend': 'Enforcer backend', + 'risk.summary.activeBindings': 'Active policies', + 'risk.summary.blockedViolations': 'Blocked violations', + 'risk.summary.auditedViolations': 'Audited violations', + 'risk.bindings.title': 'Policy bindings', + 'risk.bindings.header.agentPid': 'Agent / PID', + 'risk.bindings.header.sourcePath': 'Sensitive file', + 'risk.bindings.header.modeRevision': 'Mode / revision', + 'risk.bindings.header.state': 'State', + 'risk.bindings.header.actions': 'Actions', + 'risk.bindings.empty': 'No policy bindings yet', + 'risk.bindings.sourcePathAria': 'Policy path for binding {bindingId}', + 'risk.bindings.revisionLabel': 'Revision #{revision}', + 'risk.bindings.detachAria': 'Detach policy for Agent {agentId} (PID {pid}) on {path}', + 'risk.bindings.detaching': 'Detaching...', + 'risk.bindings.detach': 'Detach policy', + 'risk.form.title': 'Issue sensitive data exfiltration policy', + 'risk.form.sourcePathLabel': 'Sensitive file', + 'risk.form.modeLabel': 'Policy mode', + 'risk.form.scopeLabel': 'Target scope', + 'risk.form.trustedEndpointLabel': 'Trusted endpoint (optional)', + 'risk.form.sessionIdLabel': 'Session ID (optional)', + 'risk.form.scopeHelp': 'Only globally routable public IPv4; IPv6 and special-purpose addresses are not currently covered.', + 'risk.form.mode.observeOption': 'Observe: build evidence chain without affecting operations', + 'risk.form.mode.auditOption': 'Audit (recommended): evaluate rules and log without blocking', + 'risk.form.mode.enforceOption': 'Enforce: not supported by the current enforcer', + 'risk.form.mode.unreadyWarning': 'The current enforcer is not ready; policies cannot be issued.', + 'risk.form.mode.unsupportedWarning': 'The current enforcer has not declared support for this mode; policies cannot be issued.', + 'risk.form.bindingLimitWarning': 'The current ActPlane backend supports at most {maxActiveBindings} active policies; please detach existing policies before issuing new ones.', + 'risk.form.submit.loading': 'Issuing...', + 'risk.form.submit': 'Issue policy', + 'risk.violations.title': 'Policy hit records', + 'risk.violations.linkToAudit': 'View evidence chain in System Audit', + 'risk.violations.header.time': 'Time', + 'risk.violations.header.agentPid': 'Agent / PID', + 'risk.violations.header.operation': 'Operation', + 'risk.violations.header.target': 'Target', + 'risk.violations.header.result': 'Result', + 'risk.violations.header.reason': 'Reason', + 'risk.violations.empty': 'No enforcement records yet', + 'risk.violations.result.blocked': 'Blocked', + 'risk.violations.result.killed': 'Killed', + 'risk.violations.result.logged': 'Logged', + + // ── ContainmentDialog / ContainmentLifecycleCard ── + 'cont.error.sourcePolicyUnavailable': 'Source policy is no longer available; cannot safely generate enforcement rules.', + 'cont.error.rootProcessStale': 'Target process has changed; refresh and select an online Agent.', + 'cont.error.ambiguousCandidate': 'Target process identity is not unique; refresh Agent status and retry.', + 'cont.error.caseNotEligible': 'Current case status does not allow upgrading to enforcement.', + 'cont.error.caseEligibilityChanged': 'Case status has changed; close the dialog and refresh the case.', + 'cont.error.invalidDuration': 'Enforcement duration is outside the server-allowed range.', + 'cont.error.incompatibleAction': 'This case already has a different enforcement action; review the existing action first.', + 'cont.error.actionInProgress': 'Enforcement policy is being issued; refresh case status later.', + 'cont.error.actionExpiring': 'Existing enforcement policy is being lifted; retry later.', + 'cont.error.cleanupRequired': 'Old policies still need cleanup; retry later or contact an administrator.', + 'cont.error.enforcerUnavailable': 'Kernel enforcement service is temporarily unavailable; retry later.', + 'cont.error.containmentDisabled': 'Risk containment is disabled in the current environment.', + 'cont.error.recoveryFailed': 'Existing enforcement action failed to recover; handle that action first.', + 'cont.error.healthStoreUnavailable': 'Online Agent health state is temporarily unavailable; retry later.', + 'cont.error.retryable': 'Request temporarily failed, please retry later.', + 'cont.error.nonRetryable': 'Cannot complete containment; please refresh case status.', + 'cont.error.generic': 'Request failed, please retry later.', + 'cont.existing.pending.title': 'Policy is being issued', + 'cont.existing.pending.message': 'The system is waiting for kernel enforcer confirmation; check later in case details.', + 'cont.existing.activePersistent.title': 'Persistent enforcement is active', + 'cont.existing.activePersistent.message': 'This case already has a persistent kernel policy; review or lift it in case details.', + 'cont.existing.activeTemporary.title': 'Temporary enforcement is active', + 'cont.existing.activeTemporary.message': 'This case already has a temporary kernel policy; check its expiry time in case details.', + 'cont.existing.expiring.title': 'Policy is being lifted', + 'cont.existing.expiring.message': 'Kernel policy is being cleaned up; cannot issue a new one until it completes.', + 'cont.existing.expired.title': 'Previous temporary enforcement expired; you can issue a new one.', + 'cont.existing.expired.message': 'A new action will still re-verify current online process identity.', + 'cont.existing.failed.title': 'Previous enforcement failed; you can retry.', + 'cont.existing.failed.message': 'Ensure the Agent is online and the kernel enforcement service has recovered.', + 'cont.unavailable.loading': 'Reading enforcer capabilities and readiness.', + 'cont.unavailable.notReady': 'Current enforcer is not ready; cannot safely issue kernel enforcement.', + 'cont.unavailable.noCredentialEnforce': 'Current enforcer does not declare credential enforcement and policy handoff; kernel enforcement cannot be issued.', + 'cont.dialog.title.available': 'Confirm upgrade to kernel enforcement', + 'cont.dialog.title.unavailable': 'Kernel enforcement unavailable', + 'cont.dialog.subtitle': "AgentSight will derive rules from the case's source policy; the dashboard does not receive or display policy DSL.", + 'cont.dialog.closeAria': 'Close', + 'cont.plan.loading': 'Loading containment plan...', + 'cont.plan.field.sourcePath': 'Sensitive file', + 'cont.plan.field.scope': 'Enforcement scope', + 'cont.plan.field.effect': 'Execution effect', + 'cont.plan.field.originalAgent': 'Original Agent', + 'cont.plan.scope.untrustedIpv4': 'Untrusted public IPv4 targets', + 'cont.plan.effect.actplaneDeny': 'ActPlane kernel deny', + 'cont.plan.original.unavailable': 'Original process unavailable', + 'cont.target.valid': 'Process identity valid: PID {pid}', + 'cont.target.selectLabel': 'Select an online process of the same Agent', + 'cont.target.placeholder': 'Select an online Agent', + 'cont.target.recheckHint': 'Original PID is invalid; the server will re-check start time and Agent identity before issuing.', + 'cont.target.noneAvailable': 'Original process identity is invalid, and no allowed online process of the same Agent was found.', + 'cont.duration.legend': 'Enforcement duration', + 'cont.duration.temporaryAria': 'Temporary enforcement {minutes} minutes', + 'cont.duration.temporaryTitle': 'Temporary enforcement {minutes} minutes', + 'cont.duration.temporaryDesc': 'Automatically lifted by AgentSight when it expires.', + 'cont.duration.persistentAria': 'Persistent enforcement (manual lift required)', + 'cont.duration.persistentTitle': 'Persistent enforcement (manual lift required)', + 'cont.duration.persistentDesc': 'Enabled only when explicitly selected; does not expire automatically.', + 'cont.footer.close': 'Close', + 'cont.footer.cancel': 'Cancel', + 'cont.footer.submit.loading': 'Issuing...', + 'cont.footer.submit': 'Confirm and issue', + 'cont.lifecycle.title': 'Risk containment status', + 'cont.lifecycle.sectionTitle': 'Risk containment', + 'cont.lifecycle.loading': 'Loading containment status...', + 'cont.lifecycle.error': 'Containment status is temporarily unavailable; refresh and retry.', + 'cont.lifecycle.empty': 'Pending upgrade: currently audit-only, not blocking system behavior.', + 'cont.lifecycle.upgrade.retry': 'Re-issue containment', + 'cont.lifecycle.upgrade': 'Upgrade to containment', + 'cont.lifecycle.field.targetProcess': 'Target process', + 'cont.lifecycle.field.binding': 'Policy binding', + 'cont.lifecycle.field.expiresAt': 'Expiry time', + 'cont.lifecycle.field.remaining': 'Remaining time', + 'cont.lifecycle.field.firstBlocked': 'First block time', + 'cont.lifecycle.field.failureStage': 'Failure stage', + 'cont.lifecycle.field.failureSummary': 'Failure summary', + 'cont.lifecycle.expires.persistent': 'Persistent', + 'cont.lifecycle.remaining.persistent': 'Manual lift required', + 'cont.lifecycle.markResolved': 'Mark as resolved', + 'cont.failureStage.attach': 'Policy attach', + 'cont.failureStage.detach': 'Policy detach', + 'cont.failureStage.reconcile': 'State reconciliation', + 'cont.remaining.waitRefresh': 'Waiting for status refresh', + 'cont.lifecycle.pending.label': 'Waiting to execute', + 'cont.lifecycle.pending.detail': 'Policy submitted; waiting for enforcer confirmation.', + 'cont.lifecycle.activeBlocked.label': 'Contained', + 'cont.lifecycle.activeBlocked.detail': 'Kernel has confirmed blocking.', + 'cont.lifecycle.activePending.label': 'Policy active', + 'cont.lifecycle.activePending.detail': 'Waiting for the first kernel block.', + 'cont.lifecycle.expiring.label': 'Being lifted', + 'cont.lifecycle.expiring.detail': 'Enforcer is cleaning up the policy.', + 'cont.lifecycle.expired.label': 'Expired', + 'cont.lifecycle.expired.detail': 'Temporary policy has been lifted.', + 'cont.lifecycle.failed.label': 'Execution failed', + 'cont.lifecycle.failed.detail': 'After confirming runtime health, you can re-issue the policy.', + + // ── SystemAuditPage ── + 'audit.title': 'System Audit', + 'audit.badge.localData': 'AgentSight local data', + 'audit.description': 'Starting from Agent sessions, correlate tool calls, processes, files, network activity and ActPlane decisions to form a traceable evidence chain.', + 'audit.tab.overview': 'Audit overview', + 'audit.tab.sessions': 'Session audit', + 'audit.tab.cases': 'Risk cases', + 'audit.tab.events': 'Event search', + 'audit.stats.totalEvents.label': 'Audit events', + 'audit.stats.totalEvents.hint': 'Files / labels / network / decisions', + 'audit.stats.sessions.label': 'Related sessions', + 'audit.stats.sessions.hint': 'Sessions with system behavior evidence', + 'audit.stats.cases.label': 'Risk cases', + 'audit.stats.cases.hint': 'Cases formed after rule correlation', + 'audit.stats.open.label': 'Cases awaiting review', + 'audit.stats.open.hint': 'Awaiting security analyst confirmation', + 'audit.stats.blocked.label': 'Confirmed interceptions', + 'audit.stats.blocked.hint': 'Kernel has returned a deny result', + 'audit.button.viewRawEvents': 'View raw events', + 'audit.error.loadFailed': 'Failed to load system audit data', + 'audit.status.open': 'Pending review', + 'audit.status.confirmed': 'Confirmed', + 'audit.status.falsePositive': 'False positive', + 'audit.status.acceptedRisk': 'Accepted risk', + 'audit.status.resolved': 'Resolved', + 'audit.case.blocked': 'Blocked by kernel', + 'audit.case.notBlocked': 'Not confirmed blocked', + 'audit.evidence.loading': 'Loading evidence chain...', + 'audit.evidence.selectCasePlaceholder': 'Please select a risk case', + 'audit.case.summaryTitle': 'Risk conclusion', + 'audit.case.summaryLine': '{decision} · risk score {riskScore} · policy revision #{policyRevision}', + 'audit.button.confirmRisk': 'Confirm risk', + 'audit.button.markFalsePositive': 'Mark as false positive', + 'audit.button.acceptRisk': 'Accept risk', + 'audit.button.markResolved': 'Mark as resolved', + 'audit.evidence.fullChainTitle': 'Full evidence chain', + 'audit.event.fileAction': 'File read', + 'audit.event.taintTransition': 'Label propagation', + 'audit.event.networkAction': 'Network connection', + 'audit.event.policyDecision': 'Policy decision', + 'audit.event.enforcementState': 'Enforcement state', + 'audit.event.fallback': 'System event', + 'audit.evidence.fileFallback': 'Sensitive file access', + 'audit.evidence.networkFallback': 'Unknown network destination', + 'audit.evidence.policyFallback': 'Policy decision completed', + 'audit.evidence.systemEventFallback': 'System event', + 'audit.cases.empty': 'No risk cases', + 'audit.case.noSession': 'No session', + 'audit.cases.description': 'Select a case to view the complete, time-ordered raw evidence.', + 'audit.sessions.systemEvents': 'System events', + 'audit.sessions.timeRange': 'Time range', + 'audit.sessions.entryPoint': 'Entry', + 'audit.sessions.viewTimeline': 'View timeline', + 'audit.events.result': 'Result', + + // ── SkillMetricsPage ── + 'skill.error.loadFailed': 'Failed to fetch skill metrics', + 'skill.concept.note': 'This page uses a single LLM call as the unit of statistics (each corresponding to one GenAI event record).', + 'skill.summary.eventCount': 'Analyzed calls', + 'skill.summary.discoveredSkills': 'Discovered skills', + 'skill.summary.totalLoads': 'Total loads', + 'skill.summary.usageRatio': 'Skill usage ratio', + 'skill.section.loads': 'Skill loads', + 'skill.section.skillsPerCall': 'Skill count per call distribution', + 'skill.distribution.axisLabel': 'Number of skills per call', + 'skill.distribution.min': 'Min', + 'skill.distribution.max': 'Max', + 'skill.distribution.mean': 'Mean', + 'skill.section.hotnessRanking': 'Skill hotness ranking', + 'skill.hotness.granularityLabel': 'Trend granularity:', + 'skill.hotness.day': 'By day', + 'skill.hotness.week': 'By week', + 'skill.hotness.rank': 'Rank', + 'skill.hotness.skillName': 'Skill', + 'skill.hotness.totalLoads': 'Total loads', + 'skill.hotness.trend': 'Trend', } as const; export type MessageKey = keyof typeof enUSMessages; @@ -951,11 +1429,27 @@ const messages: Record> = { 'comp.interrupt.noEvents': '该会话暂无中断事件记录。', 'comp.interrupt.failedToLoad': '加载中断事件失败', 'comp.interrupt.callLabel': 'call: {id}', - 'comp.interrupt.type.llmError': 'LLM 错误', - 'comp.interrupt.type.sseTruncated': 'SSE 截断', - 'comp.interrupt.type.agentCrash': 'Agent 崩溃', - 'comp.interrupt.type.tokenLimit': 'Token 上限', - 'comp.interrupt.type.contextOverflow': '上下文溢出', + 'comp.interrupt.typeCount': '{type}:{count} 次', + + // ── Interruption types (shared across pages) ── + 'itype.llm_error': 'LLM 错误', + 'itype.sse_truncated': 'SSE 截断', + 'itype.context_overflow': '上下文溢出', + 'itype.agent_crash': 'Agent 崩溃', + 'itype.token_limit': 'Token 超限', + 'itype.rate_limit': '速率限制', + 'itype.auth_error': '鉴权错误', + 'itype.network_timeout': '网络超时', + 'itype.service_unavailable': '服务不可用', + 'itype.safety_filter': '安全过滤', + 'itype.retry_storm': '重试风暴', + 'itype.dead_loop': '死循环', + 'itype.tool_failure': '工具调用失败', + 'itype.empty_response': '空响应', + 'itype.resource_exhaustion': '资源耗尽', + 'itype.slow_response': '响应过慢', + 'itype.state_machine_error': '状态机异常', + 'itype.unauthorized_action': '未授权操作', // ── EvaluationPanel ── 'comp.eval.noResult': '暂无评估结果。', @@ -1009,9 +1503,504 @@ const messages: Record> = { 'comp.eval.finding.network_timeout': '网络超时', 'comp.eval.finding.service_unavailable': '服务不可用', 'comp.eval.finding.agent_crash': 'Agent 崩溃', + + // ── OptimizationPage / OptimizationSettings ── + 'opt.error.requestFailedStatus': '请求失败 ({status})', + 'opt.loading.analyzingTag': '分析中', + 'opt.loading.calculating': '正在计算,请稍候...', + 'opt.error.analysisFailedHint': '分析失败 —— 可稍后点击「重新分析」重试。', + 'opt.dim.notAnalyzedHint': '该维度尚未分析 —— 点击右上角「开始分析」运行。', + 'opt.summary.generating': '正在生成轨迹摘要…', + 'opt.summary.title': '轨迹摘要', + 'opt.summary.goalLabel': '目标', + 'opt.summary.processLabel': '过程', + 'opt.summary.outcomeLabel': '结果', + 'opt.copy.promptTitle': '复制优化提示词', + 'opt.failure.toolErrorLabel': '工具错误', + 'opt.failure.reasoningErrorLabel': '推理错误', + 'opt.failure.timeoutLabel': '超时', + 'opt.failure.invalidUsageLabel': '无效调用', + 'opt.accuracy.failureRecovery': '恢复: {text}', + 'opt.accuracy.table.symptom': '现象', + 'opt.accuracy.table.defectType': '缺陷类型', + 'opt.accuracy.table.rootCause': '归因对象', + 'opt.accuracy.table.fixLocus': '修复落点', + 'opt.accuracy.table.confidence': '置信度', + 'opt.accuracy.table.prompt': '优化提示词', + 'opt.accuracy.recoveredBadge': '已恢复 · 优化线索', + 'opt.accuracy.notOptimizable': '不可优化', + 'opt.accuracy.detail.evidence': '证据', + 'opt.accuracy.detail.verify': '验证', + 'opt.accuracy.detail.fix': '修复', + 'opt.accuracy.fixDiverges': '锅在 {primary},改在 {fixLocus}', + 'opt.accuracy.fixLocusNone': '无', + 'opt.accuracy.fixSame': '与根因主因一致', + 'opt.accuracy.sectionTitle': '准确性剖析', + 'opt.accuracy.issueCountTag': '{count} 个问题', + 'opt.accuracy.noIssues': '✓ 未检测到影响最终产出准确性的问题。', + 'opt.accuracy.issueTableTitle': '失败清单 · 点击任意行展开根因与修复', + 'opt.accuracy.issueTableTitleFallback': '失败清单', + 'opt.accuracy.analyzeFailed': '准确性分析失败: {msg}', + 'opt.perf.state.error': '识别失败 —— LLM 调用出错,可稍后重试。', + 'opt.perf.state.loading': 'LLM 正在分析性能数据并选择优化策略…', + 'opt.perf.state.idle': '尚未分析 —— 点击「重新分析」运行 LLM 策略选择。', + 'opt.perf.state.noStrategies': '✓ LLM 分析完成,未发现适用的优化策略。', + 'opt.perf.table.symptom': '现象', + 'opt.perf.table.strategyType': '策略类型', + 'opt.perf.table.rootCause': '根因', + 'opt.perf.table.optimization': '优化策略', + 'opt.perf.sectionTitle': '性能剖析', + 'opt.perf.timeDistributionTitle': '时间分布', + 'opt.perf.slice.model': '模型推理', + 'opt.perf.slice.tools': '工具执行', + 'opt.perf.slice.idle': '用户空闲', + 'opt.perf.totalToolCalls': '{n} 次调用', + 'opt.perf.slowestCallsTitle': '最慢调用', + 'opt.perf.table.toolName': '工具', + 'opt.perf.table.duration': '耗时', + 'opt.perf.table.command': '命令', + 'opt.perf.noToolCalls': '该会话没有工具调用,耗时全部来自模型推理', + 'opt.cost.sectionTitle': '成本剖析', + 'opt.cost.tag.headroomMeasured': '{tokens} tok · Headroom 实测 -{pct}%', + 'opt.cost.tag.headroom': '{tokens} tok', + 'opt.cost.tag.noHeadroom': '{events} 事件 · {chars} 字符', + 'opt.cost.meta': '{chars} 字符 · {events} 事件 · {calls} 步 LLM 调用 · {source}', + 'opt.cost.tokSourceMeasuredUsage': 'token 实测(usage)', + 'opt.cost.tokSourceMeasuredSteps': 'token 实测 {used}/{total} 步', + 'opt.cost.tokSourceEstimated': 'token 估算', + 'opt.tab.accuracy.name': '准确性', + 'opt.tab.accuracy.label': '准确性剖析', + 'opt.tab.perf.name': '性能', + 'opt.tab.perf.label': '性能剖析', + 'opt.tab.cost.name': '成本', + 'opt.tab.cost.label': '成本剖析', + 'opt.tabs.ariaLabel': '维度剖析', + 'opt.session.backToList': '← 返回会话列表', + 'opt.session.headerTitle': '优化分析 · 会话', + 'opt.session.viewSourceTrajectory': '🔍 查看被分析轨迹 ↗', + 'opt.session.viewAnalysisTrajectory': '🤖 查看分析轨迹 (agentsight-opt) ↗', + 'opt.session.action.analyzing': '分析中...', + 'opt.session.action.reanalyze': '重新分析', + 'opt.session.action.start': '开始分析', + 'opt.session.loadingHistory': '加载历史分析结果...', + 'opt.session.noAnalysisYet': '该会话尚未进行优化分析', + 'opt.session.startHint': '点击「开始分析」并行运行准确性 / 性能 / 成本三维度剖析', + 'opt.llm.notConfiguredHint': 'LLM 尚未配置 —— 摘要 / 性能策略 / 成本浪费 / 准确性维度需要调用 LLM。', + 'opt.llm.goToSettings': '前往设置', + 'opt.summaryRow.issueCount': '{count} 个问题', + 'opt.summaryRow.accuracyLoading': '准确性分析中...', + 'opt.summaryRow.perf': '· {toolCount} 次工具调用 · {seconds}s', + 'opt.summaryRow.cost': '· {events} 事件', + 'opt.dim.summary': '摘要', + 'opt.dim.perf': '性能', + 'opt.dim.perfStrategy': '性能策略', + 'opt.dim.cost': '成本', + 'opt.dim.costWaste': '成本浪费', + 'opt.dim.accuracy': '准确性', + 'opt.entry.subtitle': '输入会话 ID,运行准确性 / 性能 / 成本三维度剖析', + 'opt.entry.sessionIdLabel': '会话 ID', + 'opt.entry.sessionIdPlaceholder': '粘贴会话 ID,回车开始分析', + 'opt.history.title': '历史分析记录', + 'opt.history.count': '共 {n} 条', + 'opt.history.limit': '(仅显示最近 {n} 条)', + 'opt.history.loadFailed': '加载历史记录失败: {msg}', + 'opt.history.loading': '加载历史分析记录...', + 'opt.history.empty': '还没有分析记录', + 'opt.history.col.sessionId': '会话 ID', + 'opt.history.col.dimensions': '已分析维度', + 'opt.history.col.firstAnalyzed': '首次分析', + 'opt.history.col.lastUpdated': '最近更新', + 'opt.history.paginationSummary': '{count} 条结果 · 第 {page}/{total} 页', + 'opt.llm.provider.dashscope': '阿里云 DashScope', + 'opt.llm.provider.openai': 'OpenAI', + 'opt.llm.provider.deepseek': 'DeepSeek', + 'opt.llm.provider.zhipu': '智谱 GLM', + 'opt.llm.provider.moonshot': 'Moonshot 月之暗面', + 'opt.llm.provider.custom': '自定义端点', + 'opt.llm.model.glm52Reasoning': 'GLM 5.2(推理)', + 'opt.llm.model.deepseekR1Reasoning': 'DeepSeek R1(推理)', + 'opt.llm.model.o3Reasoning': 'o3(推理)', + 'opt.llm.loadFailed': '加载配置失败: {msg}', + 'opt.llm.saveFailed': '保存失败: {msg}', + 'opt.llm.title': 'LLM 配置', + 'opt.llm.subtitle': '选择云服务厂商和模型,修改后立即生效,无需重启。用于优化分析(性能策略 / 成本浪费 / 准确性维度)。', + 'opt.llm.loading': '加载配置中...', + 'opt.llm.providerLabel': '云服务商', + 'opt.llm.apiKey.placeholder': '输入 API Key', + 'opt.llm.apiKey.hide': '隐藏', + 'opt.llm.apiKey.show': '显示', + 'opt.llm.apiKey.keepUnchanged': '留空则保持当前配置不变', + 'opt.llm.apiKey.hint': '在对应厂商控制台获取 API Key', + 'opt.llm.modelLabel': '模型', + 'opt.llm.model.customOption': '✏️ 自定义模型名...', + 'opt.llm.model.customPlaceholder': '输入模型 ID', + 'opt.llm.model.placeholder': '输入模型 ID,如 gpt-4o', + 'opt.llm.saving': '保存中...', + 'opt.llm.save': '保存配置', + 'opt.llm.saved': '✓ 配置已保存并生效', + + // ── TokenFlameChart ── + 'flame.ctx.static': '静态区域', + 'flame.ctx.user': '用户提示词', + 'flame.ctx.assistant': '助手输出', + 'flame.ctx.tool': '工具输出', + 'flame.ctx.others': '其它', + 'flame.title': 'Token 火焰图', + 'flame.empty.noPerStepData': '该会话无逐步(per-step)成本数据 —— 可能是旧会话或轨迹解析失败。重新分析即可生成火焰图。', + 'flame.summary.totalTokens': '总 Token 数', + 'flame.summary.totalTokensSub': '输入 {input} · 输出 {output}', + 'flame.summary.peakContext': '峰值 Context', + 'flame.summary.peakContextSub': 'step {step} · {time} · 点击定位', + 'flame.chart.title': 'Context Window 组成 · 逐步重放', + 'flame.chart.subtitle': '点击柱体查看该步', + 'flame.legend.outputTokens': '输出 token', + 'flame.axis.contextLabel': '已发送 · context', + 'flame.waste.title': '浪费剖析', + 'flame.waste.error': '识别失败 —— LLM 调用出错,可稍后重试。', + 'flame.waste.loading': 'LLM 正在逐条判定候选是否值得优化…', + 'flame.waste.notAnalyzed': '尚未分析 —— 点击「重新分析」运行 LLM 浪费判定。', + 'flame.waste.noCandidates': '无可评估的浪费候选 —— 轨迹过短或采集内容不足,未做判定。', + 'flame.waste.noWasteFound': '✓ LLM 评估了 {count} 项候选,未发现值得优化的浪费。', + 'flame.waste.col.symptom': '现象', + 'flame.waste.col.type': '浪费类型', + 'flame.waste.col.optimization': '优化手段', + 'flame.waste.col.confidence': '置信度', + 'flame.waste.col.prompt': '优化提示词', + 'flame.confidence.high': '高', + 'flame.confidence.medium': '中', + 'flame.confidence.low': '低', + 'flame.detail.evidence': '证据', + 'flame.detail.experience': '经验 · 可直接贴进 Skill / 规范', + 'flame.detail.stepsNote': '共 {steps} 轮 · 单条轨迹证据,跨会话复现后可提升置信度', + 'flame.exp.applicability': '适用场景:', + 'flame.exp.pitfall': '错误做法:', + 'flame.exp.effectivePath': '正确做法:', + 'flame.exp.rule': '约定:', + 'flame.exp.badExample': '反例:', + 'flame.exp.goodExample': '正例:', + 'flame.exp.scope': '适用范围:', + 'flame.exp.symptom': '现象:', + 'flame.exp.optimization': '优化手段:', + 'flame.exp.evidence': '证据:', + 'flame.copyPromptTitle': '复制优化提示词', + 'flame.detail.stepTitle': '第 {id} 步', + 'flame.detail.currentOutput': '本轮输出', + + // ── AgentSessionsPage ── + 'as.daysAgo': '{n} 天前', + 'as.timePreset.last24h': '最近 24h', + 'as.timePreset.last7d': '最近 7d', + 'as.timePreset.last30d': '最近 30d', + 'as.source.ebpf': 'eBPF 采集', + 'as.source.log': '日志采集', + 'as.fetchError': '获取会话列表失败', + 'as.totalSessions': '共 {n} 个会话', + 'as.autoRefresh': '自动刷新', + 'as.filter.allSources': '全部', + 'as.searchPlaceholder': '搜索会话 ID / 项目 / 消息内容...', + 'as.loadingSessions': '正在加载会话列表...', + 'as.noMatchingSessions': '没有匹配的会话', + 'as.noSessionsInRange': '当前时间范围内没有会话数据', + 'as.source': '来源', + 'as.project': '项目', + 'as.firstMessage': '首条消息', + 'as.lastMessage': '最新消息', + 'as.lastActive': '最近活跃', + 'as.copySessionId': '复制会话 ID', + 'as.subagentBadgeTitle': '该会话派生了 {n} 个子代理', + 'as.subagentCount': '{n} 子代理', + 'as.openInNewWindow': '点击在新窗口查看轨迹详情', + 'as.metricsTooltip': '消息/步数 {count} · Tokens {inTokens} / {outTokens}', + 'as.runOptimizationTitle': '对该会话运行优化分析', + 'as.analyze': '🔬 分析', + 'as.paginationSummary': '{total} 条结果 · 第 {cur}/{totalPages} 页', + + // ── Small shared components / SettingsPage ── + 'comp.agentHealth.crashToast': '⚠️ Agent "{name}" (PID {pid}) 异常退出,影响了进行中的对话', + 'comp.agentHealth.hungToast': '⏳ Agent "{name}" (PID {pid}) 响应超时,可能卡顿', + 'comp.settings.title': '⚙️ 设置', + 'comp.settings.description': '管理 Dashboard 的全局配置。', + + // ── RiskEnforcementPage ── + 'risk.error.requestFailed': '请求失败', + 'risk.mode.observe': '观察', + 'risk.mode.audit': '审计', + 'risk.mode.enforce': '拦截', + 'risk.bindingState.pending': '等待下发', + 'risk.bindingState.enforced': '执行中', + 'risk.bindingState.failed': '失败', + 'risk.bindingState.degraded': '降级', + 'risk.bindingState.detaching': '解除中', + 'risk.bindingState.detached': '已解除', + 'risk.effect.notify': '记录', + 'risk.effect.block': '拦截', + 'risk.effect.kill': '终止', + 'risk.operation.invalidPid': 'PID 必须是正整数', + 'risk.operation.createSucceeded': '策略已生效', + 'risk.operation.detachSucceeded': '策略已解除', + 'risk.detach.confirm': '确认解除这条风险拦截策略?', + 'risk.readiness.ready': '运行中', + 'risk.readiness.unavailable': '不可用', + 'risk.readiness.checking': '检测中', + 'risk.capability.loading': '正在读取执行器能力与就绪状态', + 'risk.capability.testDev': '测试/开发执行器:包含模拟拦截与策略交接', + 'risk.capability.prod': '生产执行器:仅支持观察与审计,不提供凭据拦截或策略交接', + 'risk.title.enforce': '风险拦截', + 'risk.title.observeAudit': '风险观察与审计', + 'risk.subtitle': '以敏感文件为数据源,通过 taint 传播关联网络外发,并按观察、审计、拦截渐进生效。', + 'risk.refresh.loading': '刷新中...', + 'risk.summary.enforcerStatus': '执行器状态', + 'risk.summary.backend': '执行后端', + 'risk.summary.activeBindings': '生效策略', + 'risk.summary.blockedViolations': '拦截违规', + 'risk.summary.auditedViolations': '审计违规', + 'risk.bindings.title': '策略绑定', + 'risk.bindings.header.agentPid': 'Agent / PID', + 'risk.bindings.header.sourcePath': '敏感文件', + 'risk.bindings.header.modeRevision': '模式 / 版本', + 'risk.bindings.header.state': '状态', + 'risk.bindings.header.actions': '操作', + 'risk.bindings.empty': '暂无策略绑定', + 'risk.bindings.sourcePathAria': '策略路径 {bindingId}', + 'risk.bindings.revisionLabel': '修订 #{revision}', + 'risk.bindings.detachAria': '解除 Agent {agentId}(PID {pid})对 {path} 的策略', + 'risk.bindings.detaching': '解除中...', + 'risk.bindings.detach': '解除策略', + 'risk.form.title': '下发敏感数据外发策略', + 'risk.form.sourcePathLabel': '敏感文件', + 'risk.form.modeLabel': '策略模式', + 'risk.form.scopeLabel': '目标范围', + 'risk.form.trustedEndpointLabel': '可信目标(可选)', + 'risk.form.sessionIdLabel': 'Session ID(可选)', + 'risk.form.scopeHelp': '仅全局可路由公网 IPv4;IPv6 与特殊用途地址不在当前采集范围内', + 'risk.form.mode.observeOption': '观察:建立证据链,不影响操作', + 'risk.form.mode.auditOption': '审计(推荐):规则判定并记录,不阻断', + 'risk.form.mode.enforceOption': '拦截:当前执行器不支持', + 'risk.form.mode.unreadyWarning': '当前执行器尚未就绪,无法下发策略。', + 'risk.form.mode.unsupportedWarning': '当前执行器未声明此模式的能力,无法下发策略。', + 'risk.form.bindingLimitWarning': '当前 ActPlane 后端最多支持 {maxActiveBindings} 条生效策略;请先解除现有策略后再下发。', + 'risk.form.submit.loading': '下发中...', + 'risk.form.submit': '下发策略', + 'risk.violations.title': '策略命中记录', + 'risk.violations.linkToAudit': '进入系统审计查看证据链', + 'risk.violations.header.time': '时间', + 'risk.violations.header.agentPid': 'Agent / PID', + 'risk.violations.header.operation': '操作', + 'risk.violations.header.target': '目标', + 'risk.violations.header.result': '结果', + 'risk.violations.header.reason': '原因', + 'risk.violations.empty': '暂无拦截记录', + 'risk.violations.result.blocked': '已拦截', + 'risk.violations.result.killed': '已终止', + 'risk.violations.result.logged': '已记录', + + // ── ContainmentDialog / ContainmentLifecycleCard ── + 'cont.error.sourcePolicyUnavailable': '原始策略来源已不可用,无法安全生成拦截规则。', + 'cont.error.rootProcessStale': '目标进程已变化,请刷新后选择在线 Agent。', + 'cont.error.ambiguousCandidate': '目标进程身份不唯一,请刷新 Agent 状态后重试。', + 'cont.error.caseNotEligible': '当前案件状态不允许升级为拦截。', + 'cont.error.caseEligibilityChanged': '案件状态已变化,请关闭弹窗并刷新案件。', + 'cont.error.invalidDuration': '拦截时长不在服务端允许的范围内。', + 'cont.error.incompatibleAction': '该案件已有不同的拦截动作,请先查看现有动作。', + 'cont.error.actionInProgress': '拦截策略正在下发,请稍后刷新案件状态。', + 'cont.error.actionExpiring': '现有拦截策略正在解除,请稍后重试。', + 'cont.error.cleanupRequired': '旧策略仍需清理,请稍后重试或联系管理员。', + 'cont.error.enforcerUnavailable': '内核执行服务暂不可用,请稍后重试。', + 'cont.error.containmentDisabled': '当前环境未启用风险拦截能力。', + 'cont.error.recoveryFailed': '现有拦截动作恢复失败,请先处理该动作。', + 'cont.error.healthStoreUnavailable': '在线 Agent 状态暂不可用,请稍后重试。', + 'cont.error.retryable': '请求暂时失败,请稍后重试。', + 'cont.error.nonRetryable': '无法完成拦截,请刷新案件状态。', + 'cont.error.generic': '请求失败,请稍后重试。', + 'cont.existing.pending.title': '策略正在下发', + 'cont.existing.pending.message': '系统正在等待内核执行器确认,请稍后在案件详情中查看。', + 'cont.existing.activePersistent.title': '持续拦截已生效', + 'cont.existing.activePersistent.message': '该案件已有持续生效的内核策略,请在案件详情中查看或解除。', + 'cont.existing.activeTemporary.title': '临时拦截已生效', + 'cont.existing.activeTemporary.message': '该案件已有临时内核策略,请在案件详情中查看到期时间。', + 'cont.existing.expiring.title': '策略正在解除', + 'cont.existing.expiring.message': '内核策略正在清理,完成前不能重复下发。', + 'cont.existing.expired.title': '上次临时拦截已到期,可重新下发', + 'cont.existing.expired.message': '新动作仍会重新校验当前在线进程身份。', + 'cont.existing.failed.title': '上次拦截失败,可重新尝试', + 'cont.existing.failed.message': '请确认 Agent 在线且内核执行服务已恢复。', + 'cont.unavailable.loading': '正在读取执行器能力与就绪状态。', + 'cont.unavailable.notReady': '当前执行器尚未就绪,无法安全下发内核拦截。', + 'cont.unavailable.noCredentialEnforce': '当前执行器未显式声明凭据拦截与策略交接能力,无法下发内核拦截。', + 'cont.dialog.title.available': '确认升级为内核拦截', + 'cont.dialog.title.unavailable': '内核拦截不可用', + 'cont.dialog.subtitle': 'AgentSight 将从案件原始策略生成规则,Dashboard 不接收或展示策略 DSL。', + 'cont.dialog.closeAria': '关闭', + 'cont.plan.loading': '正在加载拦截方案...', + 'cont.plan.field.sourcePath': '敏感文件', + 'cont.plan.field.scope': '拦截范围', + 'cont.plan.field.effect': '执行效果', + 'cont.plan.field.originalAgent': '原始 Agent', + 'cont.plan.scope.untrustedIpv4': '不受信任的公网 IPv4 目标', + 'cont.plan.effect.actplaneDeny': 'ActPlane 内核拒绝(deny)', + 'cont.plan.original.unavailable': '原始进程不可用', + 'cont.target.valid': '进程身份有效:PID {pid}', + 'cont.target.selectLabel': '选择同一 Agent 的在线进程', + 'cont.target.placeholder': '请选择在线 Agent', + 'cont.target.recheckHint': '原始 PID 已失效;服务端会在下发前再次校验所选进程的启动时间与 Agent 身份。', + 'cont.target.noneAvailable': '原始进程身份已失效,且当前未发现执行器允许的同一 Agent 在线进程。', + 'cont.duration.legend': '拦截时长', + 'cont.duration.temporaryAria': '临时拦截 {minutes} 分钟', + 'cont.duration.temporaryTitle': '临时拦截 {minutes} 分钟', + 'cont.duration.temporaryDesc': '到期后由 AgentSight 自动解除。', + 'cont.duration.persistentAria': '持续拦截(需手动解除)', + 'cont.duration.persistentTitle': '持续拦截(需手动解除)', + 'cont.duration.persistentDesc': '仅在明确选择后启用,不会自动到期。', + 'cont.footer.close': '关闭', + 'cont.footer.cancel': '取消', + 'cont.footer.submit.loading': '正在下发...', + 'cont.footer.submit': '确认并下发', + 'cont.lifecycle.title': '风险拦截状态', + 'cont.lifecycle.sectionTitle': '风险拦截', + 'cont.lifecycle.loading': '正在加载拦截状态...', + 'cont.lifecycle.error': '拦截状态暂时不可用,请刷新后重试。', + 'cont.lifecycle.empty': '待升级:当前仅审计,不阻断系统行为。', + 'cont.lifecycle.upgrade.retry': '重新下发拦截', + 'cont.lifecycle.upgrade': '升级为拦截', + 'cont.lifecycle.field.targetProcess': '目标进程', + 'cont.lifecycle.field.binding': '策略绑定', + 'cont.lifecycle.field.expiresAt': '到期时间', + 'cont.lifecycle.field.remaining': '剩余时间', + 'cont.lifecycle.field.firstBlocked': '首次阻断', + 'cont.lifecycle.field.failureStage': '失败阶段', + 'cont.lifecycle.field.failureSummary': '失败说明', + 'cont.lifecycle.expires.persistent': '持续生效', + 'cont.lifecycle.remaining.persistent': '需手动解除', + 'cont.lifecycle.markResolved': '标记已处置', + 'cont.failureStage.attach': '策略挂载', + 'cont.failureStage.detach': '策略解除', + 'cont.failureStage.reconcile': '状态恢复', + 'cont.remaining.waitRefresh': '等待状态刷新', + 'cont.lifecycle.pending.label': '等待执行', + 'cont.lifecycle.pending.detail': '策略已提交,等待执行器确认', + 'cont.lifecycle.activeBlocked.label': '已遏制', + 'cont.lifecycle.activeBlocked.detail': '内核已确认阻断', + 'cont.lifecycle.activePending.label': '策略生效', + 'cont.lifecycle.activePending.detail': '等待首次内核阻断', + 'cont.lifecycle.expiring.label': '正在解除', + 'cont.lifecycle.expiring.detail': '执行器正在清理策略', + 'cont.lifecycle.expired.label': '已到期', + 'cont.lifecycle.expired.detail': '临时策略已解除', + 'cont.lifecycle.failed.label': '执行失败', + 'cont.lifecycle.failed.detail': '可在确认运行状态后重新下发', + + // ── SystemAuditPage ── + 'audit.title': '系统审计', + 'audit.badge.localData': 'AgentSight 本地数据', + 'audit.description': '从 Agent 会话出发,关联工具调用、进程、文件、网络和 ActPlane 决策,形成可追溯证据链。', + 'audit.tab.overview': '审计总览', + 'audit.tab.sessions': '会话审计', + 'audit.tab.cases': '风险案件', + 'audit.tab.events': '事件检索', + 'audit.stats.totalEvents.label': '审计事件', + 'audit.stats.totalEvents.hint': '文件 / 标签 / 网络 / 判定', + 'audit.stats.sessions.label': '关联会话', + 'audit.stats.sessions.hint': '具备系统行为证据', + 'audit.stats.cases.label': '风险案件', + 'audit.stats.cases.hint': '规则关联后形成案件', + 'audit.stats.open.label': '待研判', + 'audit.stats.open.hint': '等待安全人员确认', + 'audit.stats.blocked.label': '确认拦截', + 'audit.stats.blocked.hint': '内核已返回拒绝结果', + 'audit.button.viewRawEvents': '查看原始事件', + 'audit.error.loadFailed': '系统审计数据加载失败', + 'audit.status.open': '待研判', + 'audit.status.confirmed': '已确认', + 'audit.status.falsePositive': '误报', + 'audit.status.acceptedRisk': '已接受', + 'audit.status.resolved': '已处置', + 'audit.case.blocked': '内核已拦截', + 'audit.case.notBlocked': '未确认阻断', + 'audit.evidence.loading': '正在加载证据链...', + 'audit.evidence.selectCasePlaceholder': '请选择一个风险案件', + 'audit.case.summaryTitle': '风险结论', + 'audit.case.summaryLine': '{decision} · 风险分 {riskScore} · 策略修订 #{policyRevision}', + 'audit.button.confirmRisk': '确认风险', + 'audit.button.markFalsePositive': '标记误报', + 'audit.button.acceptRisk': '接受风险', + 'audit.button.markResolved': '标记已处置', + 'audit.evidence.fullChainTitle': '完整证据链', + 'audit.event.fileAction': '文件读取', + 'audit.event.taintTransition': '标签传递', + 'audit.event.networkAction': '网络连接', + 'audit.event.policyDecision': '策略判定', + 'audit.event.enforcementState': '执行状态', + 'audit.event.fallback': '系统事件', + 'audit.evidence.fileFallback': '敏感文件访问', + 'audit.evidence.networkFallback': '未知网络目标', + 'audit.evidence.policyFallback': '策略完成判定', + 'audit.evidence.systemEventFallback': '系统事件', + 'audit.cases.empty': '暂无风险案件', + 'audit.case.noSession': '无会话', + 'audit.cases.description': '选择案件查看完整、按时序排列的原始证据。', + 'audit.sessions.systemEvents': '系统事件', + 'audit.sessions.timeRange': '时间范围', + 'audit.sessions.entryPoint': '入口', + 'audit.sessions.viewTimeline': '查看时间线', + 'audit.events.result': '结果', + + // ── SkillMetricsPage ── + 'skill.error.loadFailed': '获取技能指标失败', + 'skill.concept.note': '本页面统计单位为一次 LLM 调用(对应一条 GenAI 事件记录)。', + 'skill.summary.eventCount': '分析调用数', + 'skill.summary.discoveredSkills': '已发现技能', + 'skill.summary.totalLoads': '总加载次数', + 'skill.summary.usageRatio': '技能使用率', + 'skill.section.loads': '技能加载次数', + 'skill.section.skillsPerCall': '单次调用技能数分布', + 'skill.distribution.axisLabel': '单次调用技能数', + 'skill.distribution.min': '最小值', + 'skill.distribution.max': '最大值', + 'skill.distribution.mean': '均值', + 'skill.section.hotnessRanking': '技能热度排行', + 'skill.hotness.granularityLabel': '趋势粒度:', + 'skill.hotness.day': '按天', + 'skill.hotness.week': '按周', + 'skill.hotness.rank': '排名', + 'skill.hotness.skillName': '技能', + 'skill.hotness.totalLoads': '总加载次数', + 'skill.hotness.trend': '趋势', }, }; +// ─── Interruption type labels ───────────────────────────────────────────────── +// Single source of truth for interruption_type → label mapping, shared by the +// health page filter, interruption panels and evaluation findings. + +const INTERRUPTION_TYPE_KEY: Record = { + llm_error: 'itype.llm_error', + sse_truncated: 'itype.sse_truncated', + context_overflow: 'itype.context_overflow', + agent_crash: 'itype.agent_crash', + token_limit: 'itype.token_limit', + rate_limit: 'itype.rate_limit', + auth_error: 'itype.auth_error', + network_timeout: 'itype.network_timeout', + service_unavailable: 'itype.service_unavailable', + safety_filter: 'itype.safety_filter', + retry_storm: 'itype.retry_storm', + dead_loop: 'itype.dead_loop', + tool_failure: 'itype.tool_failure', + empty_response: 'itype.empty_response', + resource_exhaustion: 'itype.resource_exhaustion', + slow_response: 'itype.slow_response', + state_machine_error: 'itype.state_machine_error', + unauthorized_action: 'itype.unauthorized_action', +}; + +/** All known interruption_type identifiers (filter dropdowns etc.). */ +export const INTERRUPTION_TYPES: readonly string[] = Object.keys(INTERRUPTION_TYPE_KEY); + +/** Returns the message key for an interruption_type, if known. */ +export function interruptionTypeKey(type: string): MessageKey | undefined { + return INTERRUPTION_TYPE_KEY[type]; +} + // ─── Context & Provider ─────────────────────────────────────────────────────── interface I18nContextValue { diff --git a/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx b/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx index 32b1c65f95..8670c1a949 100644 --- a/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx +++ b/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx @@ -5,11 +5,10 @@ import { restartAgentHealth, fetchInterruptions, resolveInterruption, - INTERRUPTION_TYPE_CN, } from '../utils/apiClient'; import type { InterruptionRecord, InterruptionSeverity } from '../utils/apiClient'; import type { AgentHealthStatus } from '../types'; -import { useI18n, useLocaleTag } from '../i18n'; +import { useI18n, useLocaleTag, INTERRUPTION_TYPES, interruptionTypeKey } from '../i18n'; import type { MessageKey } from '../i18n'; // ─── Agent status section ───────────────────────────────────────────────────── @@ -514,7 +513,8 @@ const InterruptionEventRow: React.FC<{ const [resolving, setResolving] = useState(false); const dotStyle = SEVERITY_DOT[event.severity] ?? 'bg-gray-400'; - const typeLabel = event.interruption_type; + const itypeKey = interruptionTypeKey(event.interruption_type); + const typeLabel = itypeKey ? t(itypeKey) : event.interruption_type; const sevKey = SEVERITY_LABEL_KEY[event.severity]; const handleResolve = async () => { @@ -680,11 +680,14 @@ const InterruptionSection: React.FC<{ addToast: (msg: string) => void }> = ({ ad setSearch(e.target.value)} className="flex-1 min-w-[220px] border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" @@ -370,24 +379,24 @@ export const AgentSessionsPage: React.FC = () => { {/* ── Session table ── */}
{loading && merged.length === 0 ? ( -
正在加载会话列表...
+
{t('as.loadingSessions')}
) : filtered.length === 0 ? (
{search || sourceFilter !== 'all' || agentFilter !== 'all' - ? '没有匹配的会话' - : '当前时间范围内没有会话数据'} + ? t('as.noMatchingSessions') + : t('as.noSessionsInRange')}
) : ( - - - - - - - + + + + + + + @@ -396,7 +405,7 @@ export const AgentSessionsPage: React.FC = () => { key={s.session_id} className="hover:bg-gray-50 cursor-pointer transition-colors" onClick={() => window.open(`#/atif?type=session&id=${encodeURIComponent(s.session_id)}`, '_blank')} - title="点击在新窗口查看轨迹详情" + title={t('as.openInNewWindow')} > @@ -433,19 +442,19 @@ export const AgentSessionsPage: React.FC = () => { @@ -459,7 +468,7 @@ export const AgentSessionsPage: React.FC = () => { {filtered.length > PAGE_SIZE && (
- {filtered.length} 条结果 · 第 {safePage}/{totalPages} 页 + {t('as.paginationSummary', { total: filtered.length, cur: safePage, totalPages })}
); } function IssueTable({ issues }: { issues: AccIssue[] }) { + const { t } = useI18n(); const [open, setOpen] = useState(0); return (
来源Agent项目首条消息最新消息最近活跃操作{t('as.source')}{t('common.agent')}{t('as.project')}{t('as.firstMessage')}{t('as.lastMessage')}{t('as.lastActive')}{t('common.actions')}
@@ -410,14 +419,14 @@ export const AgentSessionsPage: React.FC = () => { {s.session_id} - + {s.subagent_count > 0 && ( - 🤖 {s.subagent_count} 子代理 + 🤖 {t('as.subagentCount', { n: s.subagent_count })} )} - - {timeAgo(s.last_active_ms)} + + {timeAgo(t, localeTag, s.last_active_ms)} e.stopPropagation()}>
- - - - - - + + + + + + {issues.map((it, i) => { const isOpen = open === i; - const primary = it.rootCause.find((rc) => rc.role === '主因')?.object ?? ''; - const fixDiverges = it.fixLocus !== SAME_PLACE[primary]; + const primary = it.rootCause.find((rc) => rc.role === '\u4e3b\u56e0')?.object ?? ''; + const fixDiverges = fixLocusDiverges(primary, it.fixLocus); + const fixLocusText = fixLocusLabel(it.fixLocus, t); + const fixTitle = fixDiverges + ? t('opt.accuracy.fixDiverges', { primary, fixLocus: fixLocusText }) + : t('opt.accuracy.fixSame'); + const confCls = CONF_CLS[it.confidence] ?? 'bg-gray-100 text-gray-500'; + const confKey = CONF_LABEL_KEY[it.confidence]; + const confLabel = confKey ? t(confKey) : it.confidence; + return ( setOpen(isOpen ? null : i)} > @@ -331,22 +365,26 @@ function IssueTable({ issues }: { issues: AccIssue[] }) { @@ -355,18 +393,24 @@ function IssueTable({ issues }: { issues: AccIssue[] }) {
现象缺陷类型归因对象修复落点置信度优化提示词 + {t('opt.accuracy.table.symptom')} + + {t('opt.accuracy.table.defectType')} + + {t('opt.accuracy.table.rootCause')} + + {t('opt.accuracy.table.fixLocus')} + + {t('opt.accuracy.table.confidence')} + + {t('opt.accuracy.table.prompt')} +
@@ -310,7 +344,7 @@ function IssueTable({ issues }: { issues: AccIssue[] }) { {it.symptom} {it.recovered && ( - 已恢复 · 优化线索 + {t('opt.accuracy.recoveredBadge')} )} - → {it.fixLocus} + → {fixLocusText} - - {it.confidence} + + {confLabel} {it.optimizable ? ( ) : ( - 不可优化 + + {t('opt.accuracy.notOptimizable')} + )}
-
证据
+
+ {t('opt.accuracy.detail.evidence')} +
{H(it.detail)}{' '} {it.at}
-
验证
+
+ {t('opt.accuracy.detail.verify')} +
{it.verify}
-
修复
+
+ {t('opt.accuracy.detail.fix')} +
{H(it.fix)}
@@ -385,6 +429,9 @@ function IssueTable({ issues }: { issues: AccIssue[] }) { // ── 准确性维度 Section ──────────────────────────────────────────────────────── function FailureRow({ f, index }: { f: Failure; index: number }) { + const { t } = useI18n(); + const labelKey = FAILURE_LABELS[f.failure_type]; + const label = labelKey ? t(labelKey) : f.failure_type; return (
- {FAILURE_LABELS[f.failure_type] ?? f.failure_type} + {label}

#{index + 1} {f.description}

{f.context}

- {f.recovery &&

恢复: {f.recovery}

} + {f.recovery && ( +

+ {t('opt.accuracy.failureRecovery', { text: f.recovery })} +

+ )}
); } function AccuracySection({ issues, failures }: { issues: AccIssue[]; failures: Failure[] }) { + const { t } = useI18n(); const count = issues.length > 0 ? issues.length : failures.length; + const tag = t('opt.accuracy.issueCountTag', { count }); return (
- + {count === 0 ? (
-

✓ 未检测到影响最终产出准确性的问题。

+

{t('opt.accuracy.noIssues')}

) : issues.length > 0 ? ( // 五字段正交归因表(现象 / 缺陷类型 / 归因对象 / 修复落点 / 置信度 / 可优化)
-

失败清单 · 点击任意行展开根因与修复

+

+ {t('opt.accuracy.issueTableTitle')} +

) : ( // 兼容旧会话:无五字段归因时回退到旧失败清单
-

失败清单

+

+ {t('opt.accuracy.issueTableTitleFallback')} +

{failures.map((f, i) => ( ))} @@ -435,25 +492,26 @@ function AccuracySection({ issues, failures }: { issues: AccIssue[]; failures: F // 大类配色(agentsight 主题:推理=蓝 / 工具=绿 / 用户空闲=橙) const PERF_CAT_COLOR: Record = { - 模型推理慢: '#3b82f6', - 工具执行慢: '#10b981', - 用户空闲: '#f59e0b', + '\u6a21\u578b\u63a8\u7406\u6162': '#3b82f6', + '\u5de5\u5177\u6267\u884c\u6162': '#10b981', + '\u7528\u6237\u7a7a\u95f2': '#f59e0b', }; function PerfIssueTable({ issues, state }: { issues: PerfReport | null; state: DimState }) { + const { t } = useI18n(); // 加载中 / 失败 / 未分析态 if (state !== 'done' || !issues) { return (
{state === 'error' ? ( -

识别失败 —— LLM 调用出错,可稍后重试。

+

{t('opt.perf.state.error')}

) : state === 'loading' ? (
- LLM 正在分析性能数据并选择优化策略… + {t('opt.perf.state.loading')}
) : ( -

尚未分析 —— 点击「重新分析」运行 LLM 策略选择。

+

{t('opt.perf.state.idle')}

)}
); @@ -463,7 +521,7 @@ function PerfIssueTable({ issues, state }: { issues: PerfReport | null; state: D return (

- ✓ LLM 分析完成,未发现适用的优化策略。 + {t('opt.perf.state.noStrategies')}

); @@ -475,10 +533,18 @@ function PerfIssueTable({ issues, state }: { issues: PerfReport | null; state: D - - - - + + + + @@ -509,24 +575,25 @@ function PerfIssueTable({ issues, state }: { issues: PerfReport | null; state: D } function PerfSection({ perf, issues, issuesState }: { perf: PerfStats; issues: PerfReport | null; issuesState: DimState }) { + const { t } = useI18n(); const wall = perf.wall_secs || 1; const modelPct = Math.round((perf.model_secs / wall) * 100); const toolPct = Math.round((perf.tool_secs / wall) * 100); const idlePct = Math.max(0, 100 - modelPct - toolPct); // 环形图三分:仅保留有实际占用的分类,避免 0 值扇区 const timeSlices = [ - { name: '模型推理', secs: perf.model_secs, pct: modelPct, color: '#3b82f6' }, - { name: '工具执行', secs: perf.tool_secs, pct: toolPct, color: '#10b981' }, - { name: '用户空闲', secs: perf.idle_secs, pct: idlePct, color: '#9ca3af' }, + { name: t('opt.perf.slice.model'), secs: perf.model_secs, pct: modelPct, color: '#3b82f6' }, + { name: t('opt.perf.slice.tools'), secs: perf.tool_secs, pct: toolPct, color: '#10b981' }, + { name: t('opt.perf.slice.idle'), secs: perf.idle_secs, pct: idlePct, color: '#9ca3af' }, ].filter((s) => s.secs > 1); return (
- +
-

时间分布

+

{t('opt.perf.timeDistributionTitle')}

{/* 图 + 图例整体居中(卡片比右侧表格短,同时垂直居中)*/}
@@ -552,7 +619,9 @@ function PerfSection({ perf, issues, issuesState }: { perf: PerfStats; issues: P {/* 环心承载总量,替代原先卡片底部的汇总文字 */}
{formatSecs(perf.wall_secs)} - {perf.tool_count} 次调用 + + {t('opt.perf.totalToolCalls', { n: perf.tool_count })} +
@@ -569,26 +638,37 @@ function PerfSection({ perf, issues, issuesState }: { perf: PerfStats; issues: P
-

最慢调用

+

{t('opt.perf.slowestCallsTitle')}

现象策略类型根因优化策略 + {t('opt.perf.table.symptom')} + + {t('opt.perf.table.strategyType')} + + {t('opt.perf.table.rootCause')} + + {t('opt.perf.table.optimization')} +
- - - + + + {perf.top_slow.length === 0 ? ( ) : ( perf.top_slow.slice(0, 5).map((call, i) => ( - + @@ -615,42 +695,59 @@ function PerfSection({ perf, issues, issuesState }: { perf: PerfStats; issues: P // ── 成本维度 ────────────────────────────────────────────────────────────────── function CostSection({ cost, waste, wasteState }: { cost: CostStats; waste: WasteReport | null; wasteState: DimState }) { + const { t } = useI18n(); const h = cost.headroom; const fmtK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : `${Math.round(n)}`); // findings 只渲染数据质量警告(采集退化)。旧落库 payload 里还带着已删除的 // 启发式观察条(工具返回占比/重复调用/思考占比,与火焰图和浪费诊断表重复), // 按内容过滤掉,不必等重新分析。 - const qualityFindings = cost.findings.filter(f => f.html.includes('采集不完整')); + const qualityFindings = cost.findings.filter((f) => f.html.includes('\u91c7\u96c6\u4e0d\u5b8c\u6574')); const hrSavePct = h?.headroom_save_pct ?? 0; const isReal = hrSavePct > 0; const usageSteps = cost.usage_steps ?? 0; const totalSteps = cost.calls?.length ?? 0; - const tokSource = usageSteps > 0 - ? usageSteps === totalSteps ? 'token 实测(usage)' : `token 实测 ${usageSteps}/${totalSteps} 步` - : 'token 估算'; + const tokSource = + usageSteps > 0 + ? usageSteps === totalSteps + ? t('opt.cost.tokSourceMeasuredUsage') + : t('opt.cost.tokSourceMeasuredSteps', { used: usageSteps, total: totalSteps }) + : t('opt.cost.tokSourceEstimated'); + + const totalTokens = h ? h.total_input_tok + h.total_output_tok : 0; return (
{/* 元数据脚注(原「内容体积」卡降级:字符/事件/步数/token 来源属数据质量标注,不占卡位; 「可优化 N%」估算徽标与 Headroom 卡同源,一并移除,实测分支保留) */} - {h && (h.total_input_tok + h.total_output_tok) > 0 && ( + {h && totalTokens > 0 && (
- {cost.total_chars.toLocaleString()} 字符 · {cost.total_events} 事件 · {cost.calls?.length ?? 0} 步 LLM 调用 · {tokSource} + {t('opt.cost.meta', { + chars: cost.total_chars.toLocaleString(), + events: cost.total_events, + calls: cost.calls?.length ?? 0, + source: tokSource, + })}
)} @@ -680,10 +777,10 @@ function CostSection({ cost, waste, wasteState }: { cost: CostStats; waste: Wast // ── 三 TAB 渐进式分析视图(移植自 agentopt AnalysisView.tsx)───────────────── -const TAB_DEFS: { key: AnalysisTab; name: string; label: string }[] = [ - { key: 'accuracy', name: '准确性', label: '准确性剖析' }, - { key: 'perf', name: '性能', label: '性能剖析' }, - { key: 'cost', name: '成本', label: '成本剖析' }, +const TAB_DEFS: { key: AnalysisTab; nameKey: MessageKey; labelKey: MessageKey }[] = [ + { key: 'accuracy', nameKey: 'opt.tab.accuracy.name', labelKey: 'opt.tab.accuracy.label' }, + { key: 'perf', nameKey: 'opt.tab.perf.name', labelKey: 'opt.tab.perf.label' }, + { key: 'cost', nameKey: 'opt.tab.cost.name', labelKey: 'opt.tab.cost.label' }, ]; const TAB_DOT: Record = { @@ -693,20 +790,21 @@ const TAB_DOT: Record = { }; function AnalysisView({ report, progress }: { report: AnalysisReport; progress: AnalysisProgress }) { + const { t } = useI18n(); const [tab, setTab] = useState('perf'); // Composite state per tab: perf/cost each have 2 phases (stats + LLM) - function tabState(t: AnalysisTab): { + function tabState(tk: AnalysisTab): { indicator: 'idle' | 'loading' | 'partial' | 'done' | 'error'; doneCount: number; totalCount: number; } { - if (t === 'accuracy') { + if (tk === 'accuracy') { const s = progress.accuracy; return { indicator: s, doneCount: s === 'done' ? 1 : 0, totalCount: 1 }; } - const s1 = t === 'perf' ? progress.perf : progress.cost; - const s2 = t === 'perf' ? progress.perfIssues : progress.costWaste; + const s1 = tk === 'perf' ? progress.perf : progress.cost; + const s2 = tk === 'perf' ? progress.perfIssues : progress.costWaste; if (s1 === 'idle') return { indicator: 'idle', doneCount: 0, totalCount: 2 }; if (s1 === 'error') return { indicator: 'error', doneCount: 0, totalCount: 2 }; if (s1 === 'loading') return { indicator: 'loading', doneCount: 0, totalCount: 2 }; @@ -717,31 +815,38 @@ function AnalysisView({ report, progress }: { report: AnalysisReport; progress: } const state = tabState(tab); - const currentDef = TAB_DEFS.find((t) => t.key === tab)!; + const currentDef = TAB_DEFS.find((d) => d.key === tab)!; // Content is viewable once the base stats are done (even if LLM is still running) - const contentReady = tab === 'accuracy' - ? progress.accuracy === 'done' - : (tab === 'perf' ? progress.perf === 'done' && !!report.perf : progress.cost === 'done' && !!report.cost); + const contentReady = + tab === 'accuracy' + ? progress.accuracy === 'done' + : tab === 'perf' + ? progress.perf === 'done' && !!report.perf + : progress.cost === 'done' && !!report.cost; return ( <> {/* 维度切换 Tab */} -
- {TAB_DEFS.map((t) => { - const ts = tabState(t.key); - const active = tab === t.key; +
+ {TAB_DEFS.map((tdef) => { + const ts = tabState(tdef.key); + const active = tab === tdef.key; return (
{/* 当前 tab 内容 */} - {!contentReady && state.indicator === 'error' && } - {!contentReady && state.indicator === 'idle' && } + {!contentReady && state.indicator === 'error' && } + {!contentReady && state.indicator === 'idle' && } {!contentReady && (state.indicator === 'loading' || state.indicator === 'partial') && ( - + )} {contentReady && tab === 'accuracy' && ( @@ -800,6 +905,7 @@ const IDLE_PROGRESS: AnalysisProgress = { }; function SessionAnalysisView({ sessionId }: { sessionId: string }) { + const { t } = useI18n(); const navigate = useNavigate(); const [report, setReport] = useState(EMPTY_REPORT); const [progress, setProgress] = useState(IDLE_PROGRESS); @@ -843,7 +949,9 @@ function SessionAnalysisView({ sessionId }: { sessionId: string }) { if (!cancelled) setLoadingResults(false); } })(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [sessionId]); // 维度请求失败的统一处理:400 llm_not_configured 时提示去设置里配置 LLM @@ -854,71 +962,97 @@ function SessionAnalysisView({ sessionId }: { sessionId: string }) { }, []); // 渐进式分析:按维度触发,每个维度独立更新 loading/done/error - const runDimensions = useCallback((dims: DimKey[]) => { - const has = (d: DimKey) => dims.includes(d); - setProgress((prev) => { - const next = { ...prev }; - dims.forEach((d) => { next[d] = 'loading'; }); - return next; - }); - - // summary — 叙事摘要,单次 LLM 调用,数秒 - if (has('summary')) runOptimizeDimension(sessionId, 'summary') - .then((data) => { - setReport((prev) => ({ ...prev, summary: data })); - setProgress((prev) => ({ ...prev, summary: 'done' })); - }) - .catch((e) => { handleDimError(e); setProgress((prev) => ({ ...prev, summary: 'error' })); }); - - // perf — 纯计算,毫秒级 - if (has('perf')) runOptimizeDimension(sessionId, 'perf') - .then((data) => { - setReport((prev) => ({ ...prev, perf: data })); - setProgress((prev) => ({ ...prev, perf: 'done' })); - }) - .catch((e) => { handleDimError(e); setProgress((prev) => ({ ...prev, perf: 'error' })); }); - - // perf-issues — Rust 供数 + LLM 策略选择,10-30s - if (has('perfIssues')) runOptimizeDimension(sessionId, 'perf-issues') - .then((data) => { - setReport((prev) => ({ ...prev, perf_issues: data })); - setProgress((prev) => ({ ...prev, perfIssues: 'done' })); - }) - .catch((e) => { handleDimError(e); setProgress((prev) => ({ ...prev, perfIssues: 'error' })); }); - - // cost — 纯计算,毫秒级 - if (has('cost')) runOptimizeDimension(sessionId, 'cost') - .then((data) => { - setReport((prev) => ({ ...prev, cost: data })); - setProgress((prev) => ({ ...prev, cost: 'done' })); - }) - .catch((e) => { handleDimError(e); setProgress((prev) => ({ ...prev, cost: 'error' })); }); - - // cost-waste — Rust 候选 + LLM 判定,10-30s - if (has('costWaste')) runOptimizeDimension(sessionId, 'cost-waste') - .then((data) => { - setReport((prev) => ({ ...prev, cost_waste: data })); - setProgress((prev) => ({ ...prev, costWaste: 'done' })); - }) - .catch((e) => { handleDimError(e); setProgress((prev) => ({ ...prev, costWaste: 'error' })); }); - - // accuracy — LLM 多检测器,30-60s+,不设短超时 - if (has('accuracy')) runOptimizeDimension(sessionId, 'accuracy') - .then((data) => { - setReport((prev) => ({ - ...prev, - extraction: data.extraction, - failures: data.failures, - issues: data.issues ?? [], - })); - setProgress((prev) => ({ ...prev, accuracy: 'done' })); - }) - .catch((e) => { - handleDimError(e); - setProgress((prev) => ({ ...prev, accuracy: 'error' })); - setAnalyzeError(`准确性分析失败: ${userFacingError(e)}`); + const runDimensions = useCallback( + (dims: DimKey[]) => { + const has = (d: DimKey) => dims.includes(d); + setProgress((prev) => { + const next = { ...prev }; + dims.forEach((d) => { + next[d] = 'loading'; + }); + return next; }); - }, [sessionId, handleDimError]); + + // summary — 叙事摘要,单次 LLM 调用,数秒 + if (has('summary')) + runOptimizeDimension(sessionId, 'summary') + .then((data) => { + setReport((prev) => ({ ...prev, summary: data })); + setProgress((prev) => ({ ...prev, summary: 'done' })); + }) + .catch((e) => { + handleDimError(e); + setProgress((prev) => ({ ...prev, summary: 'error' })); + }); + + // perf — 纯计算,毫秒级 + if (has('perf')) + runOptimizeDimension(sessionId, 'perf') + .then((data) => { + setReport((prev) => ({ ...prev, perf: data })); + setProgress((prev) => ({ ...prev, perf: 'done' })); + }) + .catch((e) => { + handleDimError(e); + setProgress((prev) => ({ ...prev, perf: 'error' })); + }); + + // perf-issues — Rust 供数 + LLM 策略选择,10-30s + if (has('perfIssues')) + runOptimizeDimension(sessionId, 'perf-issues') + .then((data) => { + setReport((prev) => ({ ...prev, perf_issues: data })); + setProgress((prev) => ({ ...prev, perfIssues: 'done' })); + }) + .catch((e) => { + handleDimError(e); + setProgress((prev) => ({ ...prev, perfIssues: 'error' })); + }); + + // cost — 纯计算,毫秒级 + if (has('cost')) + runOptimizeDimension(sessionId, 'cost') + .then((data) => { + setReport((prev) => ({ ...prev, cost: data })); + setProgress((prev) => ({ ...prev, cost: 'done' })); + }) + .catch((e) => { + handleDimError(e); + setProgress((prev) => ({ ...prev, cost: 'error' })); + }); + + // cost-waste — Rust 候选 + LLM 判定,10-30s + if (has('costWaste')) + runOptimizeDimension(sessionId, 'cost-waste') + .then((data) => { + setReport((prev) => ({ ...prev, cost_waste: data })); + setProgress((prev) => ({ ...prev, costWaste: 'done' })); + }) + .catch((e) => { + handleDimError(e); + setProgress((prev) => ({ ...prev, costWaste: 'error' })); + }); + + // accuracy — LLM 多检测器,30-60s+,不设短超时 + if (has('accuracy')) + runOptimizeDimension(sessionId, 'accuracy') + .then((data) => { + setReport((prev) => ({ + ...prev, + extraction: data.extraction, + failures: data.failures, + issues: data.issues ?? [], + })); + setProgress((prev) => ({ ...prev, accuracy: 'done' })); + }) + .catch((e) => { + handleDimError(e); + setProgress((prev) => ({ ...prev, accuracy: 'error' })); + setAnalyzeError(t('opt.accuracy.analyzeFailed', { msg: userFacingError(e, t) })); + }); + }, + [sessionId, handleDimError, t], + ); // 全量重新分析(「重新分析」按钮):清空已有结果后并行触发全部维度 const runAnalysis = useCallback(() => { @@ -953,10 +1087,10 @@ function SessionAnalysisView({ sessionId }: { sessionId: string }) { onClick={() => navigate('/optimization')} className="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-600 transition-colors" > - ← 返回会话列表 + {t('opt.session.backToList')}
-

优化分析 · 会话

+

{t('opt.session.headerTitle')}

{sessionId}

{/* 轨迹在新标签页打开:分析页可能正在跑维度(LLM 调用 10–60s), @@ -967,7 +1101,7 @@ function SessionAnalysisView({ sessionId }: { sessionId: string }) { rel="noopener noreferrer" className="text-xs text-blue-600 hover:text-blue-800 hover:underline" > - 🔍 查看被分析轨迹 ↗ + {t('opt.session.viewSourceTrajectory')} - 🤖 查看分析轨迹 (agentsight-opt) ↗ + {t('opt.session.viewAnalysisTrajectory')}
@@ -985,7 +1119,7 @@ function SessionAnalysisView({ sessionId }: { sessionId: string }) { disabled={running || loadingResults} className="px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors disabled:opacity-50" > - {running ? '分析中...' : hasAnyResult ? '重新分析' : '开始分析'} + {running ? t('opt.session.action.analyzing') : hasAnyResult ? t('opt.session.action.reanalyze') : t('opt.session.action.start')}
@@ -996,12 +1130,12 @@ function SessionAnalysisView({ sessionId }: { sessionId: string }) { {/* ── LLM 未配置提示 ── */} {llmNotConfigured && (
- LLM 尚未配置 —— 摘要 / 性能策略 / 成本浪费 / 准确性维度需要调用 LLM。 + {t('opt.llm.notConfiguredHint')}
)} @@ -1016,24 +1150,34 @@ function SessionAnalysisView({ sessionId }: { sessionId: string }) { {/* ── 摘要行 ── */}
{progress.accuracy === 'done' - ? `${(report.issues?.length ?? 0) || report.failures.length} 个问题` - : progress.accuracy === 'loading' ? '准确性分析中...' : ''} - {report.perf ? ` · ${report.perf.tool_count} 次工具调用 · ${Math.round(report.perf.wall_secs)}s` : ''} - {report.cost ? ` · ${report.cost.total_events} 事件` : ''} + ? t('opt.summaryRow.issueCount', { + count: (report.issues?.length ?? 0) || report.failures.length, + }) + : progress.accuracy === 'loading' + ? t('opt.summaryRow.accuracyLoading') + : ''} + {report.perf && + t('opt.summaryRow.perf', { + toolCount: report.perf.tool_count, + seconds: Math.round(report.perf.wall_secs), + })} + {report.cost && + t('opt.summaryRow.cost', { + events: report.cost.total_events, + })}
- {/* ── 内容 ── */} {loadingResults ? (
- 加载历史分析结果... + {t('opt.session.loadingHistory')}
) : !hasAnyResult && !running ? (
🔬
-

该会话尚未进行优化分析

-

点击「开始分析」并行运行准确性 / 性能 / 成本三维度剖析

+

{t('opt.session.noAnalysisYet')}

+

{t('opt.session.startHint')}

) : ( @@ -1041,20 +1185,14 @@ function SessionAnalysisView({ sessionId }: { sessionId: string }) { ); } - -// ── 会话入口(ID 直达)───────────────────────────────────────────────────────── -// 会话发现统一由「🗂️ 会话列表」页负责(它同时覆盖 eBPF 捕获的会话和 collector -// 采集的轨迹)。这里只保留 ID 直达输入框,避免两处列表口径不一致:优化分析后端 -// 支持 genai_events.db 与 trajectories.db 两个来源,而 /api/sessions 只有前者。 - -/** 维度键 → 中文标签(与分析页各 section 的叫法保持一致)*/ -const DIM_LABELS: Record = { - summary: '摘要', - perf: '性能', - perf_issues: '性能策略', - cost: '成本', - cost_waste: '成本浪费', - accuracy: '准确性', +/** 维度键 → 标签 key(与分析页各 section 的叫法保持一致)*/ +const DIM_LABELS: Record = { + summary: 'opt.dim.summary', + perf: 'opt.dim.perf', + perf_issues: 'opt.dim.perfStrategy', + cost: 'opt.dim.cost', + cost_waste: 'opt.dim.costWaste', + accuracy: 'opt.dim.accuracy', }; /** 维度标签配色:复用 SEC_TAG_CLS 的三色语义(准确性绿 / 性能蓝 / 成本橙),摘要用中性灰 */ @@ -1073,6 +1211,8 @@ const HISTORY_PAGE_SIZE = 15; const HISTORY_FETCH_LIMIT = 200; function SessionEntryView() { + const { t } = useI18n(); + const locale = useLocaleTag(); const navigate = useNavigate(); const [input, setInput] = useState(''); const [history, setHistory] = useState([]); @@ -1084,11 +1224,19 @@ function SessionEntryView() { useEffect(() => { let cancelled = false; fetchOptimizeHistory(HISTORY_FETCH_LIMIT) - .then((data) => { if (!cancelled) setHistory(data); }) - .catch((e) => { if (!cancelled) setError(userFacingError(e)); }) - .finally(() => { if (!cancelled) setLoading(false); }); - return () => { cancelled = true; }; - }, []); + .then((data) => { + if (!cancelled) setHistory(data); + }) + .catch((e) => { + if (!cancelled) setError(userFacingError(e, t)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [t]); const totalPages = Math.max(1, Math.ceil(history.length / HISTORY_PAGE_SIZE)); const safePage = Math.min(page, totalPages); @@ -1104,17 +1252,19 @@ function SessionEntryView() { {/* ── Toolbar:沿用原会话列表页的工具栏结构(标题在左,操作在右)── */}
-

优化分析

-

输入会话 ID,运行准确性 / 性能 / 成本三维度剖析

+

{t('nav.optimization')}

+

{t('opt.entry.subtitle')}

setInput(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') go(); }} - placeholder="粘贴会话 ID,回车开始分析" + onKeyDown={(e) => { + if (e.key === 'Enter') go(); + }} + placeholder={t('opt.entry.sessionIdPlaceholder')} className="w-[320px] border border-gray-300 rounded-lg px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-400" />
@@ -1130,38 +1280,47 @@ function SessionEntryView() { {/* ── 历史分析记录 ── */}
-

历史分析记录

+

{t('opt.history.title')}

{!loading && !error && history.length > 0 && ( - 共 {history.length} 条 - {history.length >= HISTORY_FETCH_LIMIT && `(仅显示最近 ${HISTORY_FETCH_LIMIT} 条)`} + {t('opt.history.count', { n: history.length })} + {history.length >= HISTORY_FETCH_LIMIT && + ` ${t('opt.history.limit', { n: HISTORY_FETCH_LIMIT })}`} )}
{error ? (
- 加载历史记录失败: {error} + {t('opt.history.loadFailed', { msg: error })}
) : loading ? (
- 加载历史分析记录... + {t('opt.history.loading')}
) : history.length === 0 ? (
📭
-

还没有分析记录

+

{t('opt.history.empty')}

) : (
工具耗时命令 + {t('opt.perf.table.toolName')} + + {t('opt.perf.table.duration')} + + {t('opt.perf.table.command')} +
- 该会话没有工具调用,耗时全部来自模型推理 + {t('opt.perf.noToolCalls')}
{call.name} + {call.name} + {formatSecs(call.dur)}{call.err ? ' ✗' : ''}
- - - - + + + + @@ -1188,17 +1347,17 @@ function SessionEntryView() { DIM_TAG_CLS[d] ?? 'bg-gray-100 text-gray-600' }`} > - {DIM_LABELS[d] ?? d} + {DIM_LABELS[d] ? t(DIM_LABELS[d]) : d} )) )} ))} @@ -1212,7 +1371,7 @@ function SessionEntryView() { {!loading && !error && history.length > HISTORY_PAGE_SIZE && (
- {history.length} 条结果 · 第 {safePage}/{totalPages} 页 + {t('opt.history.paginationSummary', { count: history.length, page: safePage, total: totalPages })}
- - - + + +
-

策略绑定

+

{t('risk.bindings.title')}

{bindingsError &&

{bindingsError}

}
会话 ID已分析维度首次分析最近更新 + {t('opt.history.col.sessionId')} + + {t('opt.history.col.dimensions')} + + {t('opt.history.col.firstAnalyzed')} + + {t('opt.history.col.lastUpdated')} +
- {fmtNs(h.created_at_ns)} + {fmtNs(h.created_at_ns, locale)} - {fmtNs(h.updated_at_ns)} + {fmtNs(h.updated_at_ns, locale)}
- - - - - + + + + + {bindings.length === 0 ? ( - + + + ) : bindings.map((binding) => ( @@ -316,7 +350,7 @@ export const RiskEnforcementPage: React.FC = () => { -

下发敏感数据外发策略

+

{t('risk.form.title')}

{capabilityLabel}

- 目标范围 + {t('risk.form.scopeLabel')}

- 仅全局可路由公网 IPv4;IPv6 与特殊用途地址不在当前采集范围内 + {t('risk.form.scopeHelp')}

Agent / PID敏感文件模式 / 版本状态操作{t('risk.bindings.header.agentPid')}{t('risk.bindings.header.sourcePath')}{t('risk.bindings.header.modeRevision')}{t('risk.bindings.header.state')}{t('risk.bindings.header.actions')}
暂无策略绑定
+ {t('risk.bindings.empty')} +
@@ -280,17 +302,23 @@ export const RiskEnforcementPage: React.FC = () => { {policyFilePath(binding.request.policy_dsl)} -
{modeLabels[binding.request.policy_mode ?? legacyBindingMode(binding.request.policy_dsl)]}
-
修订 #{binding.request.policy_revision}
+
+ {t(modeLabels[binding.request.policy_mode ?? legacyBindingMode(binding.request.policy_dsl)])} +
+
+ {t('risk.bindings.revisionLabel', { revision: binding.request.policy_revision })} +
-
{bindingStateLabels[binding.state]}
+
{t(bindingStateLabels[binding.state])}
{binding.message && (
{binding.message} @@ -300,12 +328,18 @@ export const RiskEnforcementPage: React.FC = () => {
- - - - - - + + + + + + {newestViolations.length === 0 ? ( - + + + ) : newestViolations.map((event) => ( +
时间Agent / PID操作目标结果原因{t('risk.violations.header.time')}{t('risk.violations.header.agentPid')}{t('risk.violations.header.operation')}{t('risk.violations.header.target')}{t('risk.violations.header.result')}{t('risk.violations.header.reason')}
暂无拦截记录
+ {t('risk.violations.empty')} +
- {formatTimestamp(event.occurred_at_ns)} + {formatTimestamp(event.occurred_at_ns, localeTag)}
{event.agent_id}
@@ -448,13 +496,19 @@ export const RiskEnforcementPage: React.FC = () => {
{event.operation} {event.target} - - {event.blocked ? '已拦截' : event.killed ? '已终止' : '已记录'} + + {event.blocked + ? t('risk.violations.result.blocked') + : event.killed + ? t('risk.violations.result.killed') + : t('risk.violations.result.logged')}
- effect {event.effect} ({effectLabels[event.effect]}) + effect {event.effect} ({t(effectLabels[event.effect])})
diff --git a/src/agentsight/dashboard/src/pages/SettingsPage.tsx b/src/agentsight/dashboard/src/pages/SettingsPage.tsx index d45c228604..e8687c1d11 100644 --- a/src/agentsight/dashboard/src/pages/SettingsPage.tsx +++ b/src/agentsight/dashboard/src/pages/SettingsPage.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { LlmConfigForm } from '../components/OptimizationSettings'; +import { useI18n } from '../i18n'; /** Standalone settings page hosting global dashboard configuration sections. */ export const SettingsPage: React.FC = () => { + const { t } = useI18n(); return (
-

⚙️ 设置

-

管理 Dashboard 的全局配置。

+

{t('comp.settings.title')}

+

{t('comp.settings.description')}

diff --git a/src/agentsight/dashboard/src/pages/SkillMetricsPage.tsx b/src/agentsight/dashboard/src/pages/SkillMetricsPage.tsx index 75f59d3a40..b6ca59bb72 100644 --- a/src/agentsight/dashboard/src/pages/SkillMetricsPage.tsx +++ b/src/agentsight/dashboard/src/pages/SkillMetricsPage.tsx @@ -1,4 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; +import { useI18n } from '../i18n'; +import type { MessageKey } from '../i18n'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, } from 'recharts'; @@ -23,6 +25,7 @@ function fmtNs(ns: number): string { // ─── Main Page ──────────────────────────────────────────────────────────────── export const SkillMetricsPage: React.FC = () => { + const { t } = useI18n(); const now = Date.now(); const [startMs, setStartMs] = useState(now - 7 * 24 * 3600_000); const [endMs, setEndMs] = useState(now); @@ -42,11 +45,11 @@ export const SkillMetricsPage: React.FC = () => { const data = await fetchSkillMetrics(startNs, endNs, agentName || undefined, granularity); setReport(data); } catch (e: any) { - setError(e.message || '获取技能指标失败'); + setError((e && e.message) || t('skill.error.loadFailed')); } finally { setLoading(false); } - }, [startMs, endMs, agentName, granularity]); + }, [startMs, endMs, agentName, granularity, t]); useEffect(() => { loadData(); @@ -63,19 +66,19 @@ export const SkillMetricsPage: React.FC = () => { {/* ── Filter bar ── */}
{/* Time range */} - - + + {/* Quick presets */}
- {[ - { label: '最近 1h', ms: 3600 * 1000 }, - { label: '最近 6h', ms: 6 * 3600 * 1000 }, - { label: '最近 24h', ms: 24 * 3600 * 1000 }, - { label: '最近 7d', ms: 7 * 24 * 3600 * 1000 }, - ].map(({ label, ms }) => ( + {([ + { labelKey: 'common.last1h', ms: 3600 * 1000 }, + { labelKey: 'common.last6h', ms: 6 * 3600 * 1000 }, + { labelKey: 'common.last24h', ms: 24 * 3600 * 1000 }, + { labelKey: 'common.last7d', ms: 7 * 24 * 3600 * 1000 }, + ] as { labelKey: MessageKey; ms: number }[]).map(({ labelKey, ms }) => ( ))}
{/* Agent name selector */}
- + - - - - + + + + @@ -247,7 +250,7 @@ export const SkillMetricsPage: React.FC = () => { )} {!loading && !report && !error && ( -
暂无数据
+
{t('common.noData')}
)} ); @@ -255,16 +258,22 @@ export const SkillMetricsPage: React.FC = () => { // ─── Sub-components ─────────────────────────────────────────────────────────── -const SummaryCard: React.FC<{ label: string; value: string }> = ({ label, value }) => ( -
-
{label}
-
{value}
-
-); +const SummaryCard: React.FC<{ labelKey: MessageKey; value: string }> = ({ labelKey, value }) => { + const { t } = useI18n(); + return ( +
+
{t(labelKey)}
+
{value}
+
+ ); +}; -const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( -
-

{title}

- {children} -
-); +const Section: React.FC<{ titleKey: MessageKey; children: React.ReactNode }> = ({ titleKey, children }) => { + const { t } = useI18n(); + return ( +
+

{t(titleKey)}

+ {children} +
+ ); +}; diff --git a/src/agentsight/dashboard/src/pages/SystemAuditPage.tsx b/src/agentsight/dashboard/src/pages/SystemAuditPage.tsx index 677ab5a796..816a8bad73 100644 --- a/src/agentsight/dashboard/src/pages/SystemAuditPage.tsx +++ b/src/agentsight/dashboard/src/pages/SystemAuditPage.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useI18n, useLocaleTag } from '../i18n'; +import type { MessageKey } from '../i18n'; import { ContainmentDialog } from '../components/ContainmentDialog'; import { ContainmentLifecycleCard } from '../components/ContainmentLifecycleCard'; import { @@ -22,11 +24,11 @@ const CASE_PAGE_SIZE = 10; const SESSION_PAGE_SIZE = 20; const EVENT_PAGE_SIZE = 50; -const tabs: Array<{ key: AuditTab; label: string }> = [ - { key: 'overview', label: '审计总览' }, - { key: 'sessions', label: '会话审计' }, - { key: 'cases', label: '风险案件' }, - { key: 'events', label: '事件检索' }, +const tabs: Array<{ key: AuditTab; labelKey: MessageKey }> = [ + { key: 'overview', labelKey: 'audit.tab.overview' }, + { key: 'sessions', labelKey: 'audit.tab.sessions' }, + { key: 'cases', labelKey: 'audit.tab.cases' }, + { key: 'events', labelKey: 'audit.tab.events' }, ]; const severityStyle: Record = { @@ -36,25 +38,25 @@ const severityStyle: Record = { critical: 'bg-red-100 text-red-700', }; -const statusLabel: Record = { - open: '待研判', - confirmed: '已确认', - false_positive: '误报', - accepted_risk: '已接受', - resolved: '已处置', +const statusLabel: Record = { + open: 'audit.status.open', + confirmed: 'audit.status.confirmed', + false_positive: 'audit.status.falsePositive', + accepted_risk: 'audit.status.acceptedRisk', + resolved: 'audit.status.resolved', }; -const eventLabel: Record = { - file_action: '文件读取', - taint_transition: '标签传递', - network_action: '网络连接', - policy_decision: '策略判定', - enforcement_state: '执行状态', +const eventLabel: Record = { + file_action: 'audit.event.fileAction', + taint_transition: 'audit.event.taintTransition', + network_action: 'audit.event.networkAction', + policy_decision: 'audit.event.policyDecision', + enforcement_state: 'audit.event.enforcementState', }; -function formatTime(timestampNs: number): string { +function formatTime(timestampNs: number, localeTag: string): string { if (!timestampNs) return '—'; - return new Intl.DateTimeFormat('zh-CN', { + return new Intl.DateTimeFormat(localeTag, { month: '2-digit', day: '2-digit', hour: '2-digit', @@ -63,32 +65,28 @@ function formatTime(timestampNs: number): string { }).format(timestampNs / 1_000_000); } -function errorText(error: unknown): string { - return error instanceof Error ? error.message : '系统审计数据加载失败'; +function errorText(error: unknown, t: (key: MessageKey) => string): string { + return error instanceof Error ? error.message : t('audit.error.loadFailed'); } -function decisionText(detail: SecurityRiskCaseDetail): string { +function decisionText(detail: SecurityRiskCaseDetail, t: (key: MessageKey) => string): string { const decision = detail.evidence.find((item) => item.event_type === 'policy_decision'); - const mode = decision?.event.mode; const blocked = decision?.event.blocked === true || detail.blocked; - if (blocked) return '内核已拦截'; - if (mode === 'enforce') return '请求拦截,但未确认内核阻断'; - if (mode === 'audit') return '规则命中,已放行'; - return '仅观测,未执行拦截'; + return blocked ? t('audit.case.blocked') : t('audit.case.notBlocked'); } -function evidenceSummary(item: SecurityEvidenceEvent): string { +function evidenceSummary(item: SecurityEvidenceEvent, t: (key: MessageKey) => string): string { switch (item.event_type) { case 'file_action': - return String(item.event.path ?? item.event.operation ?? '敏感文件访问'); + return String(item.event.path ?? item.event.operation ?? t('audit.evidence.fileFallback')); case 'taint_transition': return `${String(item.event.label ?? 'SENSITIVE')} · ${String(item.event.transition ?? 'add')}`; case 'network_action': - return String(item.event.destination ?? '未知网络目标'); + return String(item.event.destination ?? t('audit.evidence.networkFallback')); case 'policy_decision': - return String(item.event.reason ?? item.event.mode ?? '策略完成判定'); + return String(item.event.reason ?? item.event.mode ?? t('audit.evidence.policyFallback')); default: - return String(item.event.message ?? '系统事件'); + return String(item.event.message ?? t('audit.evidence.systemEventFallback')); } } @@ -99,17 +97,20 @@ function containmentEligible(riskCase: SecurityRiskCase): boolean { && riskCase.status !== 'resolved'; } -const StatCard: React.FC<{ label: string; value: React.ReactNode; hint: string }> = ({ - label, +const StatCard: React.FC<{ labelKey: MessageKey; value: React.ReactNode; hintKey: MessageKey }> = ({ + labelKey, value, - hint, -}) => ( -
-

{label}

-

{value}

-

{hint}

-
-); + hintKey, +}) => { + const { t } = useI18n(); + return ( +
+

{t(labelKey)}

+

{value}

+

{t(hintKey)}

+
+ ); +}; const Pagination: React.FC<{ offset: number; @@ -120,6 +121,7 @@ const Pagination: React.FC<{ }> = ({ offset, pageSize, total, loading, onChange }) => { const page = Math.floor(offset / pageSize) + 1; const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const { t } = useI18n(); return (
{offset + 1}–{Math.min(offset + pageSize, total)} / {total} @@ -130,7 +132,7 @@ const Pagination: React.FC<{ onClick={() => onChange(Math.max(0, offset - pageSize))} className="rounded border px-2 py-1 disabled:text-gray-300" > - 上一页 + {t('common.prev')} {page}/{pageCount}
@@ -148,6 +150,8 @@ const Pagination: React.FC<{ export const SystemAuditPage: React.FC = () => { const navigate = useNavigate(); + const { t } = useI18n(); + const localeTag = useLocaleTag(); const [activeTab, setActiveTab] = useState('overview'); const [summary, setSummary] = useState(null); const [cases, setCases] = useState([]); @@ -194,9 +198,9 @@ export const SystemAuditPage: React.FC = () => { setEvents(results[3].value.data.items); setEventTotal(results[3].value.data.total); } - setError(failures.length ? errorText((failures[0] as PromiseRejectedResult).reason) : ''); + setError(failures.length ? errorText((failures[0] as PromiseRejectedResult).reason, t) : ''); setLoading(false); - }, [caseOffset, eventOffset, sessionOffset]); + }, [caseOffset, eventOffset, sessionOffset, t]); useEffect(() => { void load(); @@ -216,7 +220,7 @@ export const SystemAuditPage: React.FC = () => { if (caseRequestVersion.current !== version) return; setSelectedCase(response.data); } catch (nextError) { - if (caseRequestVersion.current === version) setError(errorText(nextError)); + if (caseRequestVersion.current === version) setError(errorText(nextError, t)); } finally { if (caseRequestVersion.current === version) setDetailLoading(false); } @@ -257,7 +261,7 @@ export const SystemAuditPage: React.FC = () => { if ( reviewRequestVersion.current === reviewVersion && caseRequestVersion.current === caseVersion - ) setError(errorText(nextError)); + ) setError(errorText(nextError, t)); } finally { if ( reviewRequestVersion.current === reviewVersion @@ -282,13 +286,13 @@ export const SystemAuditPage: React.FC = () => {
-

系统审计

+

{t('audit.title')}

- AgentSight 本地数据 + {t('audit.badge.localData')}

- 从 Agent 会话出发,关联工具调用、进程、文件、网络和 ActPlane 决策,形成可追溯证据链。 + {t('audit.description')}

@@ -297,7 +301,7 @@ export const SystemAuditPage: React.FC = () => { onClick={() => navigate('/security')} className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700" > - 查看原始事件 + {t('audit.button.viewRawEvents')}
@@ -317,11 +321,11 @@ export const SystemAuditPage: React.FC = () => { )}
- - - - - + + + + +
@@ -334,7 +338,7 @@ export const SystemAuditPage: React.FC = () => { activeTab === tab.key ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50' }`} > - {tab.label} + {tab.labelKey ? t(tab.labelKey) : tab.key} ))}
@@ -343,12 +347,12 @@ export const SystemAuditPage: React.FC = () => {
-

风险案件

-

选择案件查看完整、按时序排列的原始证据。

+

{t('audit.tab.cases')}

+

{t('audit.cases.description')}

{cases.length === 0 ? ( -

暂无风险案件

+

{t('audit.cases.empty')}

) : cases.map((item) => (
- {statusLabel[item.status]} + {statusLabel[item.status] ? t(statusLabel[item.status]) : item.status} - {item.blocked ? '内核已拦截' : '未确认阻断'} + {item.blocked ? t('audit.case.blocked') : t('audit.case.notBlocked')}
@@ -388,7 +392,7 @@ export const SystemAuditPage: React.FC = () => { onClick={() => setCaseOffset((current) => Math.max(0, current - CASE_PAGE_SIZE))} className="rounded border px-2 py-1 disabled:text-gray-300" > - 上一页 + {t('common.prev')} {casePage}/{casePageCount}
@@ -407,17 +411,17 @@ export const SystemAuditPage: React.FC = () => {
{!selectedCase ? (
- {detailLoading ? '正在加载证据链...' : '请选择一个风险案件'} + {detailLoading ? t('audit.evidence.loading') : t('audit.evidence.selectCasePlaceholder')}
) : (
-

风险结论

+

{t('audit.case.summaryTitle')}

{selectedCase.summary}

- {decisionText(selectedCase)} · 风险分 {selectedCase.risk_score} · 策略修订 #{selectedCase.policy_revision} + {t('audit.case.summaryLine', { decision: decisionText(selectedCase, t), riskScore: selectedCase.risk_score, policyRevision: selectedCase.policy_revision })}

- - + + {!selectedCase.containment && ( - + )} - +
{(containmentEligible(selectedCase) || selectedCase.containment) && ( { )}
-

完整证据链

+

{t('audit.evidence.fullChainTitle')}

{sortedEvidence.map((item, index) => (
@@ -460,10 +464,10 @@ export const SystemAuditPage: React.FC = () => { }`} />
-

{eventLabel[item.event_type] || item.event_type}

+

{eventLabel[item.event_type] ? t(eventLabel[item.event_type]) : item.event_type}

PID {item.identity.pid}
-

{evidenceSummary(item)}

+

{evidenceSummary(item, t)}

))} @@ -477,16 +481,27 @@ export const SystemAuditPage: React.FC = () => { {activeTab === 'sessions' && (
-

会话审计

+
+

{t('audit.tab.sessions')}

+
排名技能总加载次数趋势{t('skill.hotness.rank')}{t('skill.hotness.skillName')}{t('skill.hotness.totalLoads')}{t('skill.hotness.trend')}
- + + + + + + + + - {sessions.map((session) => - - - - - )} + {sessions.map((session) => ( + + + + + + + ))}
会话系统事件时间范围入口
{t('common.session')}{t('audit.sessions.systemEvents')}{t('audit.sessions.timeRange')}{t('audit.sessions.entryPoint')}
{session.session_id}{session.security_event_count ?? 0}{formatTime(session.first_seen_ns ?? 0)} ~ {formatTime(session.last_seen_ns ?? 0)}
{session.session_id}{session.security_event_count ?? 0}{formatTime(session.first_seen_ns ?? 0, localeTag)} ~ {formatTime(session.last_seen_ns ?? 0, localeTag)}
{sessionTotal > SESSION_PAGE_SIZE && ( @@ -504,18 +519,27 @@ export const SystemAuditPage: React.FC = () => { {activeTab === 'events' && (
-

事件检索

- +

{t('audit.tab.events')}

+
- + + + + + + + + - {events.map((event) => - - - - - )} + {events.map((event) => ( + + + + + + + ))}
类型会话PID结果
{t('sec.eventType')}{t('common.session')}PID{t('audit.events.result')}
{eventLabel[String(event.event_type)] || event.event_type || '系统事件'}{event.session_id || '—'}{event.pid ?? '—'}{event.result || '—'}
{eventLabel[String(event.event_type)] ? t(eventLabel[String(event.event_type)]) : (event.event_type || t('audit.event.fallback'))}{event.session_id || '—'}{event.pid ?? '—'}{event.result || '—'}
{eventTotal > EVENT_PAGE_SIZE && ( diff --git a/src/agentsight/dashboard/src/utils/accuracyAttribution.ts b/src/agentsight/dashboard/src/utils/accuracyAttribution.ts new file mode 100644 index 0000000000..691299383e --- /dev/null +++ b/src/agentsight/dashboard/src/utils/accuracyAttribution.ts @@ -0,0 +1,40 @@ +// Accuracy-dimension attribution helpers for the optimization page. +// +// These operate on backend protocol values (`FixLocus`, `RootObject`), so the +// comparison logic lives here — away from the rendering layer — to keep the +// invariant explicit: protocol literals must never be localized, only their +// rendered labels are. + +import type { FixLocus } from '../types/optimization'; +import type { MessageKey } from '../i18n'; + +/// Fix locus that counts as "repairable in place" for each primary root cause. +/// A differing `fixLocus` means the blame and the repair sit in different +/// places. Values are backend `FixLocus` literals, including the Chinese `'无'` +/// sentinel the API emits for "no fix locus". +export const SAME_PLACE: Record = { + Skill: 'Skill', + Context: 'Context-policy', + Tool: 'Tool', + Model: 'Model-routing', + Env: '无', + Input: '无', + Orchestration: '', // Orchestration has no in-place fix; landing on Skill counts as divergent +}; + +/// Whether the repair locus differs from where the primary root cause sits. +export function fixLocusDiverges(primaryRootObject: string, fixLocus: string): boolean { + return fixLocus !== SAME_PLACE[primaryRootObject]; +} + +/// Display labels for `FixLocus` values. Unmapped values are English +/// identifiers already suitable for both locales and pass through unchanged. +const FIX_LOCUS_LABEL_KEY: Record = { + 无: 'opt.accuracy.fixLocusNone', +}; + +/// Renders a `FixLocus` for display, translating only the sentinel value. +export function fixLocusLabel(fixLocus: FixLocus | string, t: (key: MessageKey) => string): string { + const key = FIX_LOCUS_LABEL_KEY[fixLocus]; + return key ? t(key) : fixLocus; +} diff --git a/src/agentsight/dashboard/src/utils/apiClient.ts b/src/agentsight/dashboard/src/utils/apiClient.ts index 9c24881744..ee67d46e82 100644 --- a/src/agentsight/dashboard/src/utils/apiClient.ts +++ b/src/agentsight/dashboard/src/utils/apiClient.ts @@ -842,28 +842,6 @@ export interface ConversationInterruptionCount { types: InterruptionTypeDetail[]; } -/** Map English interruption_type keys to Chinese labels. */ -export const INTERRUPTION_TYPE_CN: Record = { - llm_error: 'LLM 错误', - sse_truncated: 'SSE 截断', - context_overflow: '上下文溢出', - agent_crash: 'Agent 崩溃', - token_limit: 'Token 超限', - rate_limit: '速率限制', - auth_error: '鉴权错误', - network_timeout: '网络超时', - service_unavailable: '服务不可用', - safety_filter: '安全过滤', - retry_storm: '重试风暴', - dead_loop: '死循环', - tool_failure: '工具调用失败', - empty_response: '空响应', - resource_exhaustion: '资源耗尽', - slow_response: '响应过慢', - state_machine_error: '状态机异常', - unauthorized_action: '未授权操作', -}; - /** * Fetch all unresolved interruptions for a session. */ diff --git a/src/agentsight/dashboard/src/utils/containmentLifecycle.ts b/src/agentsight/dashboard/src/utils/containmentLifecycle.ts index 2ca98c6fde..cd623f19e3 100644 --- a/src/agentsight/dashboard/src/utils/containmentLifecycle.ts +++ b/src/agentsight/dashboard/src/utils/containmentLifecycle.ts @@ -1,26 +1,53 @@ import type { SecurityContainmentAction } from './apiClient'; +import type { MessageKey } from '../i18n'; export interface ContainmentLifecyclePresentation { - label: string; - detail: string; + labelKey: MessageKey; + detailKey: MessageKey; style: string; } export function containmentLifecyclePresentation( action: SecurityContainmentAction, -): ContainmentLifecyclePresentation { +): ContainmentLifecyclePresentation | null { switch (action.lifecycle_state) { case 'pending': - return { label: '等待执行', detail: '策略已提交,等待执行器确认', style: 'bg-amber-100 text-amber-700' }; + return { + labelKey: 'cont.lifecycle.pending.label', + detailKey: 'cont.lifecycle.pending.detail', + style: 'bg-amber-100 text-amber-700', + }; case 'active': return action.blocked_at_ns !== null - ? { label: '已遏制', detail: '内核已确认阻断', style: 'bg-red-100 text-red-700' } - : { label: '策略生效', detail: '等待首次内核阻断', style: 'bg-blue-100 text-blue-700' }; + ? { + labelKey: 'cont.lifecycle.activeBlocked.label', + detailKey: 'cont.lifecycle.activeBlocked.detail', + style: 'bg-red-100 text-red-700', + } + : { + labelKey: 'cont.lifecycle.activePending.label', + detailKey: 'cont.lifecycle.activePending.detail', + style: 'bg-blue-100 text-blue-700', + }; case 'expiring': - return { label: '正在解除', detail: '执行器正在清理策略', style: 'bg-amber-100 text-amber-700' }; + return { + labelKey: 'cont.lifecycle.expiring.label', + detailKey: 'cont.lifecycle.expiring.detail', + style: 'bg-amber-100 text-amber-700', + }; case 'expired': - return { label: '已到期', detail: '临时策略已解除', style: 'bg-gray-100 text-gray-700' }; + return { + labelKey: 'cont.lifecycle.expired.label', + detailKey: 'cont.lifecycle.expired.detail', + style: 'bg-gray-100 text-gray-700', + }; case 'failed': - return { label: '执行失败', detail: '可在确认运行状态后重新下发', style: 'bg-red-100 text-red-700' }; + return { + labelKey: 'cont.lifecycle.failed.label', + detailKey: 'cont.lifecycle.failed.detail', + style: 'bg-red-100 text-red-700', + }; + default: + return null; } } diff --git a/src/agentsight/dashboard/tests/apiClient-regression.test.cjs b/src/agentsight/dashboard/tests/apiClient-regression.test.cjs index c2928ca937..e5f2d21ead 100644 --- a/src/agentsight/dashboard/tests/apiClient-regression.test.cjs +++ b/src/agentsight/dashboard/tests/apiClient-regression.test.cjs @@ -220,5 +220,5 @@ test('terminal containment lifecycle overrides historical blocked time', () => { blocked_at_ns: 10, }); - assert.equal(presentation.label, '已到期'); + assert.equal(presentation.labelKey, 'cont.lifecycle.expired.label'); }); diff --git a/src/agentsight/dashboard/tests/run-api-client-regression.cjs b/src/agentsight/dashboard/tests/run-api-client-regression.cjs index 1510b0f54a..de3bd5d168 100644 --- a/src/agentsight/dashboard/tests/run-api-client-regression.cjs +++ b/src/agentsight/dashboard/tests/run-api-client-regression.cjs @@ -21,6 +21,11 @@ try { 'es2020', '--lib', 'es2020,dom', + // containmentLifecycle.ts type-imports MessageKey from i18n.tsx, so the + // compiler needs JSX support to resolve that module. + '--jsx', + 'react-jsx', + '--esModuleInterop', 'src/utils/apiClient.ts', 'src/utils/containmentLifecycle.ts', 'tests/apiClient-globals.d.ts', From 6bcd0583d9046ec28a0b0546cde142425d5961eb Mon Sep 17 00:00:00 2001 From: liyuqing Date: Mon, 17 Aug 2026 17:03:30 +0800 Subject: [PATCH 3/3] refactor(sight): dedupe dashboard i18n helpers Review follow-ups for the dashboard i18n work: consolidate four copies of nanosecond timestamp formatting into utils/datetime.ts, drop the last hardcoded zh-CN locale in the security fmtTime helper, and localize the security detail-row labels via MessageKey instead of raw lowercase field names. Also unify inconsistent zh translations, remove the unused common.saved key, and add a placeholder-consistency regression test: tsc already guarantees key alignment across locales, but a missing {n} in one translation is invisible to the type system. Signed-off-by: liyuqing --- .../components/ContainmentLifecycleCard.tsx | 16 +--- .../src/components/InterruptionPanel.tsx | 5 +- src/agentsight/dashboard/src/i18n.tsx | 16 +++- .../dashboard/src/pages/AgentHealthPage.tsx | 5 +- .../dashboard/src/pages/ConversationList.tsx | 13 +-- .../src/pages/security/EventDetailDrawer.tsx | 5 +- .../src/pages/security/EventTable.tsx | 5 +- .../src/pages/security/RecentEvents.tsx | 5 +- .../src/pages/security/TimelineItem.tsx | 11 +-- .../src/pages/security/TimelineTab.tsx | 5 +- .../dashboard/src/pages/security/utils.ts | 33 ++++---- .../dashboard/src/utils/datetime.ts | 38 +++++++++ .../tests/apiClient-regression.test.cjs | 81 +++++++++++++++++++ .../dashboard/tests/i18n-regression.test.cjs | 20 ++++- .../tests/run-api-client-regression.cjs | 6 ++ 15 files changed, 195 insertions(+), 69 deletions(-) create mode 100644 src/agentsight/dashboard/src/utils/datetime.ts diff --git a/src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx b/src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx index 8a3dd021a8..691ef074c7 100644 --- a/src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx +++ b/src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx @@ -3,6 +3,7 @@ import type { SecurityContainmentAction } from '../utils/apiClient'; import { containmentLifecyclePresentation } from '../utils/containmentLifecycle'; import { useI18n, useLocaleTag } from '../i18n'; import type { MessageKey } from '../i18n'; +import { formatNsCompact } from '../utils/datetime'; interface ContainmentLifecycleCardProps { action: SecurityContainmentAction | null; @@ -23,17 +24,6 @@ const failureStageLabel: Record< reconcile: 'cont.failureStage.reconcile', }; -function formatNs(timestampNs: number | null, localeTag: string): string { - if (!timestampNs) return '—'; - return new Intl.DateTimeFormat(localeTag, { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).format(timestampNs / 1_000_000); -} - function formatRemaining( expiresAtNs: number, nowMs: number, @@ -135,7 +125,7 @@ export const ContainmentLifecycleCard: React.FC =
{t('cont.lifecycle.field.expiresAt')}
{action.expires_at_ns - ? formatNs(action.expires_at_ns, localeTag) + ? formatNsCompact(action.expires_at_ns, localeTag) : t('cont.lifecycle.expires.persistent')}
@@ -149,7 +139,7 @@ export const ContainmentLifecycleCard: React.FC =
{t('cont.lifecycle.field.firstBlocked')}
-
{formatNs(action.blocked_at_ns, localeTag)}
+
{formatNsCompact(action.blocked_at_ns, localeTag)}
{t('cont.lifecycle.field.failureStage')}
diff --git a/src/agentsight/dashboard/src/components/InterruptionPanel.tsx b/src/agentsight/dashboard/src/components/InterruptionPanel.tsx index 6add4e239f..de50771ade 100644 --- a/src/agentsight/dashboard/src/components/InterruptionPanel.tsx +++ b/src/agentsight/dashboard/src/components/InterruptionPanel.tsx @@ -14,6 +14,7 @@ import { resolveInterruption, } from '../utils/apiClient'; import { useI18n, useLocaleTag, interruptionTypeKey } from '../i18n'; +import { formatNs } from '../utils/datetime'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -24,10 +25,6 @@ const SEVERITY_DOT: Record = { low: 'bg-blue-400', }; -function formatNs(ns: number, locale: string): string { - return new Date(ns / 1_000_000).toLocaleString(locale); -} - function parseDetail(raw: string | null): React.ReactNode { if (!raw) return null; try { diff --git a/src/agentsight/dashboard/src/i18n.tsx b/src/agentsight/dashboard/src/i18n.tsx index 2d065c89a0..40c4976383 100644 --- a/src/agentsight/dashboard/src/i18n.tsx +++ b/src/agentsight/dashboard/src/i18n.tsx @@ -80,7 +80,6 @@ const enUSMessages = { 'common.input': 'Input', 'common.output': 'Output', 'common.total': 'Total', - 'common.saved': 'Saved', 'common.original': 'Original', 'common.optimized': 'Optimized', 'common.noChange': 'No change', @@ -353,6 +352,10 @@ const enUSMessages = { 'sec.category': 'Category', 'sec.result': 'Result', 'sec.verdict': 'Verdict', + 'sec.detail.verdict': 'Verdict', + 'sec.detail.error': 'Error', + 'sec.detail.reason': 'Reason', + 'sec.detail.finding': 'Finding', 'sec.session': 'Session', 'sec.run': 'Run', 'sec.call': 'Call', @@ -993,7 +996,9 @@ const enUSMessages = { export type MessageKey = keyof typeof enUSMessages; -const messages: Record> = { +// Exported for the i18n regression test (placeholder consistency checks); +// application code should always go through `t()`. +export const messages: Record> = { 'en-US': enUSMessages, 'zh-CN': { // ── App / Nav / Login ── @@ -1056,7 +1061,6 @@ const messages: Record> = { 'common.input': '输入', 'common.output': '输出', 'common.total': '总计', - 'common.saved': '已节省', 'common.original': '原始内容', 'common.optimized': '优化后', 'common.noChange': '无变更', @@ -1288,7 +1292,7 @@ const messages: Record> = { 'cl.unknownModel': '未知模型', 'cl.queryFailed': '查询失败', 'cl.loadingEllipsis': '加载中...', - 'cl.totalTokens': '总计 Tokens', + 'cl.totalTokens': '总 Token 数', // ── Security ── 'sec.securityObservability': '安全可观测', @@ -1329,6 +1333,10 @@ const messages: Record> = { 'sec.category': '类别', 'sec.result': '结果', 'sec.verdict': 'Verdict', + 'sec.detail.verdict': 'Verdict', + 'sec.detail.error': '错误', + 'sec.detail.reason': '原因', + 'sec.detail.finding': '发现', 'sec.session': 'Session', 'sec.run': 'Run', 'sec.call': 'Call', diff --git a/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx b/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx index 8670c1a949..f5b7249427 100644 --- a/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx +++ b/src/agentsight/dashboard/src/pages/AgentHealthPage.tsx @@ -10,6 +10,7 @@ import type { InterruptionRecord, InterruptionSeverity } from '../utils/apiClien import type { AgentHealthStatus } from '../types'; import { useI18n, useLocaleTag, INTERRUPTION_TYPES, interruptionTypeKey } from '../i18n'; import type { MessageKey } from '../i18n'; +import { formatNs } from '../utils/datetime'; // ─── Agent status section ───────────────────────────────────────────────────── @@ -422,10 +423,6 @@ const TIME_RANGE_KEYS: { labelKey: MessageKey; hours: number }[] = [ { labelKey: 'ah.last7Days', hours: 24 * 7 }, ]; -function formatNs(ns: number, locale: string): string { - return new Date(ns / 1_000_000).toLocaleString(locale); -} - function parseDetail(raw: string | null, t: (key: MessageKey) => string): React.ReactNode { if (!raw) return {t('ah.noDetails')}; try { diff --git a/src/agentsight/dashboard/src/pages/ConversationList.tsx b/src/agentsight/dashboard/src/pages/ConversationList.tsx index 6add0fdee9..88333cbada 100644 --- a/src/agentsight/dashboard/src/pages/ConversationList.tsx +++ b/src/agentsight/dashboard/src/pages/ConversationList.tsx @@ -12,6 +12,7 @@ import { DateTimePicker } from '../components/DateTimePicker'; import { SessionIdHelp } from '../components/SessionIdHelp'; import { useI18n, useLocaleTag } from '../i18n'; import type { MessageKey } from '../i18n'; +import { formatNsPadded as nsToDate } from '../utils/datetime'; import { fetchSessions, fetchTraces, @@ -38,18 +39,6 @@ import { // ─── Helpers ────────────────────────────────────────────────────────────────── -/** Convert nanoseconds to a display string */ -function nsToDate(ns: number, locale: string): string { - return new Date(ns / 1_000_000).toLocaleString(locale, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - /** Truncate a long ID for display */ function shortId(id: string, len = 16): string { return id.length > len ? id.slice(0, len) + '…' : id; diff --git a/src/agentsight/dashboard/src/pages/security/EventDetailDrawer.tsx b/src/agentsight/dashboard/src/pages/security/EventDetailDrawer.tsx index 9104175bbd..ae2cfc08ba 100644 --- a/src/agentsight/dashboard/src/pages/security/EventDetailDrawer.tsx +++ b/src/agentsight/dashboard/src/pages/security/EventDetailDrawer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useI18n } from '../../i18n'; +import { useI18n, useLocaleTag } from '../../i18n'; import type { SecurityApiResponse, SecurityEventDetailResponse, @@ -17,6 +17,7 @@ export const EventDetailDrawer: React.FC<{ onRetry: () => void; }> = ({ eventId, detail, loading, error, onClose, onRetry }) => { const { t } = useI18n(); + const locale = useLocaleTag(); const event = detail?.data.event; return (
@@ -71,7 +72,7 @@ export const EventDetailDrawer: React.FC<{
{[ - [t('common.time'), fmtTime(event)], + [t('common.time'), fmtTime(event, locale)], [t('sec.category'), event.category ?? '-'], [t('sec.result'), event.result ?? '-'], [t('sec.verdict'), securityEventVerdict(event)], diff --git a/src/agentsight/dashboard/src/pages/security/EventTable.tsx b/src/agentsight/dashboard/src/pages/security/EventTable.tsx index 051fbe338e..88587b91a7 100644 --- a/src/agentsight/dashboard/src/pages/security/EventTable.tsx +++ b/src/agentsight/dashboard/src/pages/security/EventTable.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useI18n } from '../../i18n'; +import { useI18n, useLocaleTag } from '../../i18n'; import type { SecurityApiResponse, SecurityEventRecord, @@ -25,6 +25,7 @@ export const EventTable: React.FC<{ onViewTimeline?: (sessionId: string, runId: string) => void; }> = ({ response, loading, error, onSelect, onPage, onViewTimeline }) => { const { t } = useI18n(); + const locale = useLocaleTag(); const data = response?.data; const items = data?.items ?? []; const previousOffset = Math.max(0, (data?.offset ?? 0) - (data?.limit ?? EVENT_PAGE_SIZE)); @@ -73,7 +74,7 @@ export const EventTable: React.FC<{ onClick={() => onSelect(event.event_id)} className="cursor-pointer hover:bg-gray-50" > -
{fmtTime(event)}{fmtTime(event, locale)} {event.category ?? '-'} diff --git a/src/agentsight/dashboard/src/pages/security/RecentEvents.tsx b/src/agentsight/dashboard/src/pages/security/RecentEvents.tsx index c4460c0034..6a24216573 100644 --- a/src/agentsight/dashboard/src/pages/security/RecentEvents.tsx +++ b/src/agentsight/dashboard/src/pages/security/RecentEvents.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useI18n } from '../../i18n'; +import { useI18n, useLocaleTag } from '../../i18n'; import type { SecurityEventRecord } from '../../utils/apiClient'; import { badgeClasses, fmtTime, securityEventVerdict, verdictBadgeClasses } from './utils'; @@ -8,6 +8,7 @@ export const RecentEvents: React.FC<{ onSelect: (eventId: string) => void; }> = ({ events, onSelect }) => { const { t } = useI18n(); + const locale = useLocaleTag(); const columns = 'grid-cols-[128px_120px_minmax(180px,1fr)_110px_110px]'; return (
@@ -34,7 +35,7 @@ export const RecentEvents: React.FC<{ onClick={() => onSelect(event.event_id)} className={`grid w-full ${columns} items-center gap-3 px-4 py-3 text-left hover:bg-gray-50`} > - {fmtTime(event)} + {fmtTime(event, locale)} {event.category ?? '-'} diff --git a/src/agentsight/dashboard/src/pages/security/TimelineItem.tsx b/src/agentsight/dashboard/src/pages/security/TimelineItem.tsx index 34fc28b421..637577cfda 100644 --- a/src/agentsight/dashboard/src/pages/security/TimelineItem.tsx +++ b/src/agentsight/dashboard/src/pages/security/TimelineItem.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useI18n } from '../../i18n'; +import { useI18n, useLocaleTag } from '../../i18n'; import type { SecurityTimelineItem } from '../../utils/apiClient'; import { badgeClasses, @@ -19,6 +19,7 @@ export const TimelineItem: React.FC<{ onSelectEvent: (eventId: string) => void; }> = ({ item, observabilityItemsById, onSelectEvent }) => { const { t } = useI18n(); + const locale = useLocaleTag(); const securityEvent = item.kind === 'security' ? item.event : undefined; const observabilityContext = timelineObservabilityContext(item, observabilityItemsById); const eventTitle = securityEvent?.event_type ?? securityEvent?.event_id; @@ -59,7 +60,7 @@ export const TimelineItem: React.FC<{ {item.redacted && {t('sec.redacted')}} {item.truncated && {t('sec.truncated')}}
-

{fmtTime(item)}

+

{fmtTime(item, locale)}

{item.match && ( @@ -93,9 +94,9 @@ export const TimelineItem: React.FC<{ {detailRows.length > 0 && (
{detailRows.map((row) => ( -
-

{row.label}

-

+

+

{t(row.labelKey)}

+

{row.value}

diff --git a/src/agentsight/dashboard/src/pages/security/TimelineTab.tsx b/src/agentsight/dashboard/src/pages/security/TimelineTab.tsx index 15e409c582..27d0e5992a 100644 --- a/src/agentsight/dashboard/src/pages/security/TimelineTab.tsx +++ b/src/agentsight/dashboard/src/pages/security/TimelineTab.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useI18n } from '../../i18n'; +import { useI18n, useLocaleTag } from '../../i18n'; import type { SecurityApiResponse, SecurityEventRecord, @@ -57,6 +57,7 @@ export const TimelineTab: React.FC<{ onSelectEvent, }) => { const { t } = useI18n(); + const locale = useLocaleTag(); return (
@@ -88,7 +89,7 @@ export const TimelineTab: React.FC<{ {(securityRuns?.data.items ?? []).map((run) => ( ))} diff --git a/src/agentsight/dashboard/src/pages/security/utils.ts b/src/agentsight/dashboard/src/pages/security/utils.ts index 7605eaa69b..9c5ffee0a3 100644 --- a/src/agentsight/dashboard/src/pages/security/utils.ts +++ b/src/agentsight/dashboard/src/pages/security/utils.ts @@ -1,4 +1,5 @@ import { SecurityApiClientError } from '../../utils/apiClient'; +import { formatMsCompact } from '../../utils/datetime'; import type { MessageKey } from '../../i18n'; import type { SecurityCountItem, @@ -42,16 +43,10 @@ export function timestampToMs(input: { return null; } -export function fmtTime(input: Parameters[0]): string { +export function fmtTime(input: Parameters[0], locale: string): string { const ms = timestampToMs(input); if (ms == null) return '-'; - return new Date(ms).toLocaleString('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); + return formatMsCompact(ms, locale); } export function errorMessage( @@ -123,11 +118,11 @@ export function timelineObservabilityContext( }; } -const SECURITY_DETAIL_FIELDS: Array<{ label: string; keys: string[] }> = [ - { label: 'verdict', keys: ['verdict'] }, - { label: 'error', keys: ['error_message', 'error', 'message'] }, - { label: 'reason', keys: ['reason', 'policy_reason', 'explanation'] }, - { label: 'finding', keys: ['finding', 'findings'] }, +const SECURITY_DETAIL_FIELDS: Array<{ id: string; labelKey: MessageKey; keys: string[] }> = [ + { id: 'verdict', labelKey: 'sec.detail.verdict', keys: ['verdict'] }, + { id: 'error', labelKey: 'sec.detail.error', keys: ['error_message', 'error', 'message'] }, + { id: 'reason', labelKey: 'sec.detail.reason', keys: ['reason', 'policy_reason', 'explanation'] }, + { id: 'finding', labelKey: 'sec.detail.finding', keys: ['finding', 'findings'] }, ]; export function findDetailValue(value: unknown, keys: string[], depth = 0): unknown { @@ -152,16 +147,18 @@ export function findDetailValue(value: unknown, keys: string[], depth = 0): unkn return undefined; } -export function securityDetailRows(details: unknown): Array<{ label: string; value: string }> { - const rows: Array<{ label: string; value: string }> = []; +export function securityDetailRows( + details: unknown, +): Array<{ id: string; labelKey: MessageKey; value: string }> { + const rows: Array<{ id: string; labelKey: MessageKey; value: string }> = []; const seen = new Set(); for (const field of SECURITY_DETAIL_FIELDS) { const value = findDetailValue(details, field.keys); if (value === undefined || value === null) continue; const preview = recordPreview(value); - if (preview === '-' || seen.has(`${field.label}:${preview}`)) continue; - seen.add(`${field.label}:${preview}`); - rows.push({ label: field.label, value: preview }); + if (preview === '-' || seen.has(`${field.id}:${preview}`)) continue; + seen.add(`${field.id}:${preview}`); + rows.push({ id: field.id, labelKey: field.labelKey, value: preview }); } return rows; } diff --git a/src/agentsight/dashboard/src/utils/datetime.ts b/src/agentsight/dashboard/src/utils/datetime.ts new file mode 100644 index 0000000000..d3293d1a8e --- /dev/null +++ b/src/agentsight/dashboard/src/utils/datetime.ts @@ -0,0 +1,38 @@ +// Shared locale-aware timestamp formatting helpers. All AgentSight event +// timestamps are nanoseconds since epoch; JS Date wants milliseconds. + +const NS_PER_MS = 1_000_000; + +/** Convert a nanosecond timestamp to the locale's default date-time string. */ +export function formatNs(ns: number, locale: string): string { + return new Date(ns / NS_PER_MS).toLocaleString(locale); +} + +/** Zero-padded full date-time (`YYYY-MM-DD HH:mm:ss` style) for list views. */ +export function formatNsPadded(ns: number, locale: string): string { + return new Date(ns / NS_PER_MS).toLocaleString(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +/** Compact `MM-DD HH:mm:ss` variant working on milliseconds. */ +export function formatMsCompact(ms: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(ms); +} + +/** Compact variant on nanoseconds; renders '—' for null/zero timestamps. */ +export function formatNsCompact(ns: number | null, locale: string): string { + if (!ns) return '—'; + return formatMsCompact(ns / NS_PER_MS, locale); +} diff --git a/src/agentsight/dashboard/tests/apiClient-regression.test.cjs b/src/agentsight/dashboard/tests/apiClient-regression.test.cjs index e5f2d21ead..ebac6d11e7 100644 --- a/src/agentsight/dashboard/tests/apiClient-regression.test.cjs +++ b/src/agentsight/dashboard/tests/apiClient-regression.test.cjs @@ -15,6 +15,21 @@ const { const { containmentLifecyclePresentation, } = require(process.env.AGENTSIGHT_CONTAINMENT_LIFECYCLE_BUILD); +const { + formatNs, + formatNsPadded, + formatMsCompact, + formatNsCompact, +} = require(process.env.AGENTSIGHT_DATETIME_BUILD); +const { + fmtTime, + securityDetailRows, +} = require(process.env.AGENTSIGHT_SECURITY_UTILS_BUILD); +const { + SAME_PLACE, + fixLocusDiverges, + fixLocusLabel, +} = require(process.env.AGENTSIGHT_ACCURACY_ATTRIBUTION_BUILD); function enforcementHealth(alternatePidRetarget) { return { @@ -222,3 +237,69 @@ test('terminal containment lifecycle overrides historical blocked time', () => { assert.equal(presentation.labelKey, 'cont.lifecycle.expired.label'); }); + +// 2026-08-17T08:00:00Z expressed in nanoseconds. +const SAMPLE_NS = 1_786_608_000_000_000_000; + +test('datetime helpers honor the requested locale', () => { + // The exact rendering depends on the host timezone, so assert on + // locale-sensitive differences rather than a fixed string. + assert.notEqual(formatNs(SAMPLE_NS, 'en-US'), formatNs(SAMPLE_NS, 'zh-CN')); + assert.match(formatNsPadded(SAMPLE_NS, 'zh-CN'), /\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}/); + assert.equal( + formatNsCompact(SAMPLE_NS, 'zh-CN'), + formatMsCompact(SAMPLE_NS / 1_000_000, 'zh-CN'), + ); +}); + +test('formatNsCompact renders a dash for missing timestamps', () => { + assert.equal(formatNsCompact(null, 'en-US'), '—'); + assert.equal(formatNsCompact(0, 'zh-CN'), '—'); +}); + +test('fmtTime formats via the caller-provided locale', () => { + const event = { timestamp_ns: SAMPLE_NS }; + assert.equal(fmtTime(event, 'zh-CN'), formatMsCompact(SAMPLE_NS / 1_000_000, 'zh-CN')); + assert.equal(fmtTime(event, 'en-US'), formatMsCompact(SAMPLE_NS / 1_000_000, 'en-US')); + assert.equal(fmtTime({}, 'en-US'), '-'); +}); + +test('securityDetailRows emits stable ids and message keys', () => { + const rows = securityDetailRows({ + verdict: 'deny', + reason: 'policy matched', + nested: { error_message: 'boom' }, + }); + + assert.deepEqual( + rows.map((row) => ({ id: row.id, labelKey: row.labelKey })), + [ + { id: 'verdict', labelKey: 'sec.detail.verdict' }, + { id: 'error', labelKey: 'sec.detail.error' }, + { id: 'reason', labelKey: 'sec.detail.reason' }, + ], + ); + assert.equal(rows.find((row) => row.id === 'verdict').value, 'deny'); + assert.equal(rows.find((row) => row.id === 'error').value, 'boom'); +}); + +// The API serializes `FixLocus::None` as the Chinese sentinel '无'. Translating +// that literal in SAME_PLACE would make every Env/Input issue look divergent. +test('fix locus comparison uses protocol values, not translated labels', () => { + assert.equal(SAME_PLACE.Env, '无'); + assert.equal(SAME_PLACE.Input, '无'); + + assert.equal(fixLocusDiverges('Env', '无'), false); + assert.equal(fixLocusDiverges('Input', '无'), false); + assert.equal(fixLocusDiverges('Env', 'Skill'), true); + assert.equal(fixLocusDiverges('Skill', 'Skill'), false); + // Orchestration has no in-place fix, so any locus counts as divergent. + assert.equal(fixLocusDiverges('Orchestration', 'Skill'), true); +}); + +test('fixLocusLabel translates only the sentinel value', () => { + const t = (key) => (key === 'opt.accuracy.fixLocusNone' ? 'None' : `??${key}`); + assert.equal(fixLocusLabel('无', t), 'None'); + assert.equal(fixLocusLabel('Skill', t), 'Skill'); + assert.equal(fixLocusLabel('Context-policy', t), 'Context-policy'); +}); diff --git a/src/agentsight/dashboard/tests/i18n-regression.test.cjs b/src/agentsight/dashboard/tests/i18n-regression.test.cjs index 7ab5a33eb2..85e4ac7209 100644 --- a/src/agentsight/dashboard/tests/i18n-regression.test.cjs +++ b/src/agentsight/dashboard/tests/i18n-regression.test.cjs @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const test = require('node:test'); -const { resolveLocale } = require(process.env.AGENTSIGHT_I18N_BUILD); +const { resolveLocale, messages, SUPPORTED_LOCALES } = require(process.env.AGENTSIGHT_I18N_BUILD); test('resolveLocale selects the first supported browser locale', () => { assert.equal(resolveLocale(null, ['fr-FR', 'zh-CN']), 'zh-CN'); @@ -23,3 +23,21 @@ test('resolveLocale skips falsy browser languages', () => { assert.equal(resolveLocale(null, [undefined, 'zh-CN']), 'zh-CN'); assert.equal(resolveLocale(null, [undefined]), 'en-US'); }); + +// tsc already guarantees key alignment across locales; placeholder sets are +// invisible to the type system, so a missing `{n}` in one translation would +// silently leak the raw brace text into the UI. +test('every message uses identical placeholders across locales', () => { + const placeholders = (msg) => (msg.match(/\{[a-zA-Z_]+\}/g) ?? []).sort().join(','); + const [baseLocale, ...otherLocales] = SUPPORTED_LOCALES; + for (const key of Object.keys(messages[baseLocale])) { + const expected = placeholders(messages[baseLocale][key]); + for (const locale of otherLocales) { + assert.equal( + placeholders(messages[locale][key]), + expected, + `placeholder mismatch for '${key}' between ${baseLocale} and ${locale}`, + ); + } + } +}); diff --git a/src/agentsight/dashboard/tests/run-api-client-regression.cjs b/src/agentsight/dashboard/tests/run-api-client-regression.cjs index de3bd5d168..2f8390e629 100644 --- a/src/agentsight/dashboard/tests/run-api-client-regression.cjs +++ b/src/agentsight/dashboard/tests/run-api-client-regression.cjs @@ -28,6 +28,9 @@ try { '--esModuleInterop', 'src/utils/apiClient.ts', 'src/utils/containmentLifecycle.ts', + 'src/utils/datetime.ts', + 'src/utils/accuracyAttribution.ts', + 'src/pages/security/utils.ts', 'tests/apiClient-globals.d.ts', ], { stdio: 'inherit' }, @@ -37,6 +40,9 @@ try { ...process.env, AGENTSIGHT_API_CLIENT_BUILD: join(outputDir, 'utils', 'apiClient.js'), AGENTSIGHT_CONTAINMENT_LIFECYCLE_BUILD: join(outputDir, 'utils', 'containmentLifecycle.js'), + AGENTSIGHT_DATETIME_BUILD: join(outputDir, 'utils', 'datetime.js'), + AGENTSIGHT_ACCURACY_ATTRIBUTION_BUILD: join(outputDir, 'utils', 'accuracyAttribution.js'), + AGENTSIGHT_SECURITY_UTILS_BUILD: join(outputDir, 'pages', 'security', 'utils.js'), }, stdio: 'inherit', });