feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection

- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru
- Auto-detect system language via navigator.languages; fallback to English
- Add BCP47 locale mapping for date formatting and sorting
- Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each)
- Update language selector with native language names
- Extend locale key parity test to cover all 7 languages (70 assertions)
- Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features
- Add credential encryption, host metrics, session backup crypto, and zmodem bridge
- Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
This commit is contained in:
Logan.Li committed 2026-08-12 00:07:48 +08:00
1 parent fea6d8db04
commit b0d0e3a5f3
301 files changed
+28002 -2705

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface AgentMarkdownProps {
content: string
className?: string
}
/** 简单检测是否含 Markdown 结构(标题/表格/引用/列表/粗体) */
export function looksLikeMarkdown(text: string): boolean {
const t = text.trim()
if (!t) return false
return (
/^#{1,3}\s/m.test(t) ||
/^\|.+\|/m.test(t) ||
/^>\s/m.test(t) ||
/^[-*]\s/m.test(t) ||
/^\d+\.\s/m.test(t) ||
/\*\*[^*]+\*\*/.test(t) ||
/```/.test(t)
)
}
/** Agent 结论富文本:对齐 aiops AgentMarkdown,使用本产品色板 */
export function AgentMarkdown({ content, className = '' }: AgentMarkdownProps) {
return (
<div className={`agent-md min-w-0 ${className}`.trim()}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="mb-2 mt-3 text-base font-bold text-content-primary first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-1.5 mt-3 text-sm font-semibold text-content-primary first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-1 mt-2 text-sm font-medium text-content-primary first:mt-0">{children}</h3>
),
p: ({ children }) => (
<p className="my-1.5 text-xs leading-relaxed text-content-secondary">{children}</p>
),
ul: ({ children }) => (
<ul className="my-1.5 list-disc space-y-0.5 pl-5 text-xs text-content-secondary">{children}</ul>
),
ol: ({ children }) => (
<ol className="my-1.5 list-decimal space-y-0.5 pl-5 text-xs text-content-secondary">{children}</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
strong: ({ children }) => (
<strong className="font-semibold text-content-primary">{children}</strong>
),
blockquote: ({ children }) => (
<blockquote className="my-2 border-l-2 border-accent-blue/50 pl-3 text-xs italic text-txt-muted">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="border-dark-700/60 my-2 overflow-x-auto rounded-md border">
<table className="w-full border-collapse text-[11px]">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-dark-900/80">{children}</thead>,
th: ({ children }) => (
<th className="border-dark-700/60 whitespace-nowrap border-b px-2.5 py-1.5 text-left font-medium text-content-primary">
{children}
</th>
),
td: ({ children }) => (
<td className="border-dark-700/40 border-b px-2.5 py-1.5 text-content-secondary">{children}</td>
),
tr: ({ children }) => <tr className="hover:bg-dark-800/40">{children}</tr>,
code: ({ className: codeClass, children }) => {
const isBlock = Boolean(codeClass?.includes('language-'))
if (isBlock) {
return (
<pre className="bg-dark-950/60 my-2 overflow-x-auto rounded px-2 py-1.5 text-[11px] text-content-secondary">
<code>{children}</code>
</pre>
)
}
return (
<code className="bg-dark-800 rounded px-1 py-0.5 text-[11px] text-content-primary">{children}</code>
)
},
hr: () => <hr className="border-dark-700/60 my-3" />,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-accent-blue underline-offset-2 hover:underline"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}
+325 -186
View File
@@ -1,13 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Check,
CheckCircle2,
Clipboard,
Loader2,
Play,
Server,
ShieldAlert,
ShieldCheck,
Plus,
Send,
Sparkles,
Square,
TerminalSquare,
@@ -18,15 +17,18 @@ import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount }
import { writeClipboardText } from '../../platform/clipboard'
import { analyzeAgentOutput } from '../../utils/agentOutput'
import {
AGENT_RISK_LABEL,
AGENT_SAFETY_MODES,
agentCommandAllowFingerprint,
agentCommandNeedsConfirmation,
assessAgentCommandRisk,
normalizeAgentSafetyMode,
type AgentCommandRisk,
type AgentSafetyMode,
} from '../../utils/agentCommandRisk'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { useSettingsStore } from '../../stores/settingsStore'
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
import i18n from '../../i18n'
import { translateErrorMessage } from '../../i18n/format'
export interface TerminalAiContext {
selection: string
@@ -50,16 +52,27 @@ interface AgentStep {
status: 'awaiting' | 'running' | 'done' | 'error'
}
interface ThreadMessage {
id: string
role: 'user' | 'assistant'
content: string
steps?: AgentStep[]
}
const MAX_AGENT_STEPS = 8
const MAX_TOOL_OUTPUT_CHARS = 12_000
const MAX_AGENT_CONTEXT_CHARS = 48_000
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'balanced', 'permissive']
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'relaxed']
function truncateMiddle(value: string, limit: number): string {
if (value.length <= limit) return value
const head = Math.floor(limit * 0.65)
const tail = limit - head
return `${value.slice(0, head)}\n\n… [输出过长,已截断 ${value.length - limit} 个字符] …\n\n${value.slice(-tail)}`
return `${value.slice(0, head)}\n\n… [已截断 ${value.length - limit} 字符] …\n\n${value.slice(-tail)}`
}
function messageId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
}
function agentPrompt(
@@ -67,7 +80,8 @@ function agentPrompt(
targetLabel: string,
round: number,
consecutiveFailures: number,
protocolRetry: boolean
protocolRetry: boolean,
isFollowUp: boolean
): string {
const checkpoint =
consecutiveFailures >= 2
@@ -78,12 +92,13 @@ function agentPrompt(
? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。'
: ''
return [
`用户任务:${task}`,
isFollowUp ? `用户追问:${task}` : `用户任务:${task}`,
`执行目标:当前已连接 SSH 主机「${targetLabel}」。`,
`当前为 Agent 第 ${round}/${MAX_AGENT_STEPS} 轮。`,
isFollowUp ? '请结合对话摘要与历史工具结果继续处理;不要重复已完成的无信息量步骤。' : '',
'你只有 ssh-exec 一个工具,可执行当前远端 shell 中的单行命令。',
'如果仍需获取信息或执行操作:只返回一个 bash 代码块,块内只能有一行命令。',
'如果任务已完成、已无需命令,或仅根据已有上下文就能回答:以“FINAL:”开头给出简短结论,不要包含代码块。',
'如果任务已完成、已无需命令,或仅根据已有上下文就能回答:以“FINAL:”开头给出结论,不要包含代码块。列表、表格、标题可用 Markdown,便于阅读。',
'不得声称执行过尚未出现在真实工具结果中的命令。',
protocolRetry ? '上一次回答没有遵循工具协议;这一次必须二选一:唯一单行 bash 代码块,或以 FINAL: 开头的结论。' : '',
checkpoint,
@@ -92,8 +107,28 @@ function agentPrompt(
.join('\n')
}
function agentContext(initialSelection: string, steps: AgentStep[]): string {
function compressHistory(messages: ThreadMessage[]): string {
const parts: string[] = []
for (const message of messages) {
if (message.role === 'user') {
parts.push(`【用户】\n${message.content}`)
continue
}
parts.push(`【助手结论】\n${message.content}`)
for (const step of (message.steps || []).slice(-4)) {
parts.push(
`【历史 ssh-exec】\n$ ${step.command}\n${truncateMiddle(step.output || '无输出', 1_500)}${
step.exitCode === undefined ? '' : `\n[exit_code=${step.exitCode}]`
}`
)
}
}
return truncateMiddle(parts.join('\n\n'), Math.floor(MAX_AGENT_CONTEXT_CHARS * 0.55))
}
function agentContext(initialSelection: string, steps: AgentStep[], history: string): string {
const parts: string[] = []
if (history) parts.push(`【对话与工具摘要】\n${history}`)
if (initialSelection) parts.push(`【用户选中的初始终端内容】\n${initialSelection}`)
for (const step of steps) {
parts.push(
@@ -112,62 +147,93 @@ export default function AiTerminalPanel({
targetLabel = '当前 SSH 会话',
isLocal = false,
}: AiTerminalPanelProps) {
const { t } = useTranslation('ai')
const store = useAiProviderStore()
const safetyMode = useSettingsStore((state) => state.settings.agentSafetyMode)
const safetyMode = normalizeAgentSafetyMode(
useSettingsStore((state) => state.settings.agentSafetyMode)
)
const setSetting = useSettingsStore((state) => state.setSetting)
const [prompt, setPrompt] = useState('')
const [result, setResult] = useState('')
const [contextNote, setContextNote] = useState('仅在开始时读取当前选中内容')
const [messages, setMessages] = useState<ThreadMessage[]>([])
const [liveSteps, setLiveSteps] = useState<AgentStep[]>([])
const [error, setError] = useState('')
const [running, setRunning] = useState(false)
const [copied, setCopied] = useState(false)
const [steps, setSteps] = useState<AgentStep[]>([])
const [copiedId, setCopiedId] = useState('')
const runIdRef = useRef(0)
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const sessionAllowRef = useRef(new Set<string>())
const timelineRef = useRef<HTMLDivElement>(null)
const initialSelectionRef = useRef('')
const liveStepsRef = useRef<AgentStep[]>([])
const { confirmAgentRisk, dismissAgentRisk, ConfirmDialogComponent } = useConfirmDialog()
liveStepsRef.current = liveSteps
useEffect(() => {
void store.load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const node = timelineRef.current
if (!node) return
node.scrollTop = node.scrollHeight
}, [messages, liveSteps, error, running])
const provider = store.providers.find((item) => item.id === store.activeProviderId && item.enabled)
const model = provider?.models.find((item) => item.id === provider.defaultModelId)
const sshReady = !!connectionId && !isLocal
const startNewTopic = () => {
if (running) {
runIdRef.current += 1
setRunning(false)
}
setMessages([])
setLiveSteps([])
setError('')
setPrompt('')
setCopiedId('')
sessionAllowRef.current.clear()
initialSelectionRef.current = ''
}
const runAgent = async () => {
setError('')
setResult('')
setSteps([])
if (!provider || !model) {
setError('请先在“设置 → 大模型”添加 Provider 并选择默认模型')
setError(t('error.noProvider'))
return
}
if (!provider.hasApiKey && provider.authStyle !== 'none') {
setError(`Provider「${provider.name}」尚未保存 API Key`)
setError(t('error.noApiKey', { provider: provider.name }))
return
}
const task = prompt.trim()
if (!task) {
setError('请输入要执行的任务')
setError(t('error.emptyTask'))
return
}
if (!connectionId || isLocal) {
setError('Agent CLI 只作用于当前已连接的远程 SSH 会话')
if (!sshReady) {
setError(t('error.sshRequired'))
return
}
const isFollowUp = messages.length > 0
const history = compressHistory(messages)
const userMessage: ThreadMessage = { id: messageId(), role: 'user', content: task }
setMessages((prev) => [...prev, userMessage])
setPrompt('')
const runId = runIdRef.current + 1
runIdRef.current = runId
const selection = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
setContextNote(
selection
? `已附带选中内容 · ${terminalContextLineCount(selection)} 行`
: '未附带终端内容 · Agent 将通过 ssh-exec 自主检查'
)
if (!isFollowUp) {
initialSelectionRef.current = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
}
const selection = initialSelectionRef.current
setRunning(true)
setLiveSteps([])
const runSafetyMode = safetyMode
const completedSteps: AgentStep[] = []
const commandCounts = new Map<string, number>()
let redactedCount = 0
let consecutiveFailures = 0
let protocolRetry = false
try {
@@ -176,14 +242,13 @@ export default function AiTerminalPanel({
provider,
model,
action: 'execute',
prompt: agentPrompt(task, targetLabel, round, consecutiveFailures, protocolRetry),
context: agentContext(selection, completedSteps),
prompt: agentPrompt(task, targetLabel, round, consecutiveFailures, protocolRetry, isFollowUp),
context: agentContext(selection, completedSteps, history),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
throw new Error(completion.error || '模型未返回内容')
throw new Error(completion.error || i18n.t('ai:error.modelEmpty'))
}
redactedCount += completion.redactedCount || 0
const blocks = extractBashBlocks(completion.text)
if (blocks.length === 0) {
@@ -192,55 +257,80 @@ export default function AiTerminalPanel({
protocolRetry = true
continue
}
throw new Error('模型连续两次未遵循 Agent 工具协议,已停止以避免误判')
throw new Error(i18n.t('ai:error.protocolFailed'))
}
const finalAnswer = completion.text.replace(/^\s*FINAL\s*:\s*/i, '').trim()
if (!finalAnswer) throw new Error('模型未返回可用的最终结论')
setResult(finalAnswer)
if (!finalAnswer) throw new Error(i18n.t('ai:error.noConclusion'))
setMessages((prev) => [
...prev,
{
id: messageId(),
role: 'assistant',
content: finalAnswer,
steps: completedSteps.map((step) => ({ ...step })),
},
])
setLiveSteps([])
return
}
if (blocks.length !== 1) {
throw new Error('模型一次返回了多个命令,已拒绝执行')
throw new Error(i18n.t('ai:error.multipleCommands'))
}
const command = singleLineTerminalInsert(blocks[0])
if (!command) {
throw new Error('模型未返回唯一的单行命令,已拒绝执行')
throw new Error(i18n.t('ai:error.invalidCommand'))
}
protocolRetry = false
const repeated = (commandCounts.get(command) || 0) + 1
commandCounts.set(command, repeated)
if (repeated > 2) {
throw new Error(`检测到重复命令循环,已在执行前停止:${command}`)
throw new Error(i18n.t('ai:error.repeatLoop', { command }))
}
const risk = assessAgentCommandRisk(command)
if (risk === 'critical') {
throw new Error(`命令命中破坏性操作硬红线,不提供确认放行:${command}`)
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
}
const proposed: AgentStep = { round, command, risk, status: 'running' }
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
const awaiting = { ...proposed, status: 'awaiting' as const }
setSteps([...completedSteps, awaiting])
const approved = await confirm({
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
message: `目标:SSH · ${targetLabel}\n安全模式:${AGENT_SAFETY_MODES[runSafetyMode].label}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
danger: risk === 'high',
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
})
if (!approved) {
setSteps([...completedSteps])
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
return
const fingerprint = agentCommandAllowFingerprint(command, risk)
if (!sessionAllowRef.current.has(fingerprint)) {
const awaiting = { ...proposed, status: 'awaiting' as const }
setLiveSteps([...completedSteps, awaiting])
const choice = await confirmAgentRisk({
title: t('step.confirmTitle', { round, risk: t(`risk.${risk}`) }),
message: t('step.confirmMessage', {
target: targetLabel,
mode: t(`safety.${runSafetyMode}.label`),
command,
}),
danger: risk === 'high',
})
if (runIdRef.current !== runId) return
if (choice === 'reject') {
setLiveSteps([])
setMessages((prev) => [
...prev,
{ id: messageId(), role: 'assistant', content: t('step.rejected', { round }), steps: [...completedSteps] },
])
return
}
if (choice === 'always') {
sessionAllowRef.current.add(fingerprint)
}
}
}
setSteps([...completedSteps, proposed])
setLiveSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command)
if (runIdRef.current !== runId) return
if (!executed.success && /已终止/.test(executed.error || '')) {
return
}
const rawOutput = executed.success
? executed.result?.trim() || '命令执行完成,无输出'
: `ssh-exec 失败:${executed.error || '未知错误'}`
? executed.result?.trim() || i18n.t('ai:error.execNoOutput')
: i18n.t('ai:error.execFailed', { error: executed.error || i18n.t('ai:error.execUnknown') })
const output =
rawOutput.length > MAX_TOOL_OUTPUT_CHARS / 2
? truncateMiddle(analyzeAgentOutput(rawOutput).text, MAX_TOOL_OUTPUT_CHARS)
@@ -255,70 +345,75 @@ export default function AiTerminalPanel({
}
completedSteps.push(step)
consecutiveFailures = step.status === 'error' ? consecutiveFailures + 1 : 0
setSteps([...completedSteps])
setLiveSteps([...completedSteps])
}
throw new Error(`已达到 ${MAX_AGENT_STEPS} 步上限,任务尚未收敛,请缩小任务范围后重试`)
throw new Error(i18n.t('ai:error.maxSteps', { max: MAX_AGENT_STEPS }))
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
const message = translateErrorMessage(caught instanceof Error ? caught.message : String(caught))
setError(message)
if (completedSteps.length > 0) {
setMessages((prev) => [
...prev,
{ id: messageId(), role: 'assistant', content: message, steps: [...completedSteps] },
])
setLiveSteps([])
}
} finally {
if (runIdRef.current === runId) {
setRunning(false)
if (redactedCount > 0) {
setContextNote((current) => `${current} · 发送前本地脱敏 ${redactedCount} 处`)
}
}
}
}
const copyResult = async () => {
await writeClipboardText(result)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
const copyMessage = async (id: string, content: string) => {
await writeClipboardText(content)
setCopiedId(id)
window.setTimeout(() => setCopiedId(''), 1200)
}
const stopAgent = () => {
runIdRef.current += 1
dismissAgentRisk()
if (connectionId) {
void window.opsxApi.sshExecCancel(connectionId)
}
setRunning(false)
setResult('任务已停止;正在执行的单条 SSH 命令不会被强制中断,但不会再继续下一步。')
const snapped = liveStepsRef.current
.filter((step) => step.status !== 'awaiting')
.map((step) =>
step.status === 'running'
? { ...step, status: 'error' as const, output: step.output || t('step.terminated') }
: step
)
setLiveSteps([])
setMessages((prev) => [
...prev,
{
id: messageId(),
role: 'assistant',
content: t('step.stoppedMessage'),
steps: snapped.length > 0 ? snapped : undefined,
},
])
}
return (
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
<header className="border-dark-700/50 flex items-center gap-2 border-b px-3 py-2.5">
<Sparkles className="h-4 w-4 text-accent-blue" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">Agent CLI</div>
<div className="truncate text-[10px] text-txt-faint">
{provider && model ? `LLM: ${provider.name} · ${model.label}` : '尚未配置模型'}
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2.5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent-blue" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{t('panel.title')}</div>
<div className="truncate text-[10px] text-txt-faint">
{provider && model
? t('panel.modelConfigured', { provider: provider.name, model: model.label })
: t('panel.modelNotConfigured')}
{' · '}
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
</div>
</div>
</div>
<span className="rounded-md border border-dark-600 bg-dark-900/70 px-2 py-1 text-[10px] text-content-secondary">
{AGENT_SAFETY_MODES[safetyMode].label}
</span>
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-3">
<div className="border-accent-blue/20 bg-accent-blue/5 space-y-2 rounded-lg border p-3">
<div className="text-xs font-medium text-content-primary">自然语言驱动 SSH 自主排障与部署</div>
<div className="flex items-center gap-1.5 text-[11px] text-content-secondary">
<Server className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate">已连接主机:{connectionId && !isLocal ? targetLabel : '尚未连接远程 SSH'}</span>
</div>
<p className="text-[10px] leading-relaxed text-txt-faint">
Agent 只使用当前 SSH 连接执行、观察并继续处理,不切换目标主机。
</p>
</div>
<div className="rounded-lg border border-dark-700/60 bg-dark-900/45 p-2.5">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium text-content-secondary">
<ShieldCheck className="h-3.5 w-3.5 text-accent-green" /> 执行安全模式
</div>
<div className="grid grid-cols-3 gap-1" role="radiogroup" aria-label="Agent 安全模式">
<div className="flex items-center gap-0.5" role="radiogroup" aria-label={t('safety.aria')}>
{SAFETY_MODE_ORDER.map((mode) => {
const option = AGENT_SAFETY_MODES[mode]
const selected = safetyMode === mode
return (
<button
@@ -326,25 +421,96 @@ export default function AiTerminalPanel({
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`safety.${mode}.label`)}
disabled={running}
title={`${option.short}:${option.description}`}
title={t(`safety.${mode}.description`)}
onClick={() => void setSetting('agentSafetyMode', mode)}
className={`rounded-md border px-2 py-1.5 text-[11px] transition-colors disabled:opacity-50 ${
selected
? 'border-accent-blue/50 bg-accent-blue/15 text-accent-blue'
: 'border-dark-700 bg-dark-800 text-txt-muted hover:text-content-primary'
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
}`}
>
{option.label}
{t(`safety.${mode}.label`)}
</button>
)
})}
</div>
<p className="mt-2 text-[10px] leading-relaxed text-txt-faint">
{AGENT_SAFETY_MODES[safetyMode].short} · {AGENT_SAFETY_MODES[safetyMode].description}
</p>
<button
type="button"
title={t('panel.newTopic')}
aria-label={t('panel.newTopic')}
disabled={running}
onClick={startNewTopic}
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
<button type="button" title={t('panel.close')} onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
</button>
</div>
</header>
<div ref={timelineRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3">
{messages.length === 0 && liveSteps.length === 0 && !running && (
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">
{t('panel.emptyHint')}
</p>
)}
{messages.map((message) => (
<div key={message.id} className="space-y-2">
{message.role === 'user' ? (
<div className="ml-6 rounded-lg bg-accent-blue/10 px-3 py-2 text-xs leading-relaxed text-content-primary">
{message.content}
</div>
) : (
<div className="space-y-2">
{(message.steps || []).map((step) => (
<StepCard key={`${message.id}-${step.round}-${step.command}`} step={step} />
))}
<div className="mr-4 rounded-lg border border-emerald-500/15 bg-emerald-500/5 px-3 py-2">
<div className="mb-1 flex items-center gap-1.5 text-[10px] text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" /> {t('panel.conclusion')}
</div>
{looksLikeMarkdown(message.content) ? (
<AgentMarkdown content={message.content} />
) : (
<div className="whitespace-pre-wrap break-words text-xs leading-relaxed text-content-secondary">
{message.content}
</div>
)}
<button
type="button"
onClick={() => void copyMessage(message.id, message.content)}
className="mt-2 inline-flex items-center gap-1 text-[10px] text-txt-faint hover:text-content-secondary"
>
{copiedId === message.id ? <Check className="h-3 w-3" /> : <Clipboard className="h-3 w-3" />}
{copiedId === message.id ? t('panel.copied') : t('panel.copy')}
</button>
</div>
</div>
)}
</div>
))}
{liveSteps.map((step) => (
<StepCard key={`live-${step.round}-${step.command}`} step={step} />
))}
{running && liveSteps.length === 0 && (
<div className="flex items-center gap-1.5 text-[11px] text-txt-faint">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('panel.thinking')}
</div>
)}
{error && (
<div role="alert" className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
</div>
<div className="border-dark-700/50 flex-shrink-0 border-t px-3 py-2.5">
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
@@ -354,91 +520,64 @@ export default function AiTerminalPanel({
if (!running) void runAgent()
}
}}
rows={5}
rows={3}
disabled={running}
placeholder="输入任务描述,回车发送(Shift+回车换行)"
className="ui-input-sm min-h-24 resize-none leading-relaxed disabled:opacity-70"
placeholder={t('panel.placeholder')}
className="ui-input-sm min-h-[72px] resize-none leading-relaxed disabled:opacity-70"
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-txt-faint">Shift+回车换行 · 回车发送</span>
<div className="mt-2 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[10px] text-txt-faint">
{messages.length > 0
? t('panel.hintFollowUp')
: capture().selection.trim()
? t('panel.hintSelection', { lines: terminalContextLineCount(capture().selection) })
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
</span>
{running ? (
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex min-w-20 items-center justify-center gap-1.5">
<Square className="h-3.5 w-3.5" /> 停止
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex items-center gap-1.5">
<Square className="h-3.5 w-3.5" /> {t('panel.stop')}
</button>
) : (
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5">
<Play className="h-3.5 w-3.5" /> 执行
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex items-center gap-1.5">
<Send className="h-3.5 w-3.5" /> {t('panel.send')}
</button>
)}
</div>
<div className="text-[10px] text-txt-faint">{contextNote}</div>
{error && (
<div
role="alert"
className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300"
>
{error}
</div>
)}
{steps.length > 0 && (
<div className="space-y-2" aria-label="Agent 执行步骤">
{steps.map((step) => (
<div
key={`${step.round}-${step.command}`}
className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5"
>
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-txt-faint">
{step.status === 'running' || step.status === 'awaiting' ? (
<Loader2 className={`h-3.5 w-3.5 ${step.status === 'running' ? 'animate-spin' : ''}`} />
) : step.status === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : (
<TerminalSquare className="h-3.5 w-3.5 text-amber-300" />
)}
第 {step.round} 步 ·{' '}
{step.status === 'awaiting' ? '等待确认' : step.status === 'running' ? '正在执行' : '已观察'}
<span className="ml-auto">{AGENT_RISK_LABEL[step.risk]}</span>
</div>
<code className="block break-all text-xs text-content-secondary">$ {step.command}</code>
{step.output && (
<pre className="border-dark-700/40 mt-2 max-h-44 overflow-auto whitespace-pre-wrap break-words border-t pt-2 text-[11px] text-txt-muted">
{step.output}
</pre>
)}
</div>
))}
</div>
)}
{result && (
<div className="space-y-2">
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" /> Agent 结论
</div>
<div className="whitespace-pre-wrap break-words text-xs leading-relaxed text-content-secondary">
{result}
</div>
</div>
<button
type="button"
onClick={() => void copyResult()}
className="ui-btn-secondary flex items-center gap-1.5"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Clipboard className="h-3.5 w-3.5" />}
{copied ? '已复制' : '复制结论'}
</button>
</div>
)}
<p className="mt-2 text-[10px] text-txt-faint">{t('panel.footerNote')}</p>
</div>
<footer className="border-dark-700/40 flex items-start gap-2 border-t px-3 py-2 text-[10px] leading-relaxed text-txt-faint">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
默认均衡模式仅高风险确认;critical 永久拒绝。最多 {MAX_AGENT_STEPS} 步,命令、对话和输出不落盘。
</footer>
{ConfirmDialogComponent}
</aside>
)
}
function StepCard({ step }: { step: AgentStep }) {
const { t } = useTranslation('ai')
const statusLabel =
step.status === 'awaiting'
? t('step.awaiting')
: step.status === 'running'
? t('step.running')
: t('step.done')
return (
<div className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-txt-faint">
{step.status === 'running' || step.status === 'awaiting' ? (
<Loader2 className={`h-3.5 w-3.5 ${step.status === 'running' ? 'animate-spin' : ''}`} />
) : step.status === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : (
<TerminalSquare className="h-3.5 w-3.5 text-amber-300" />
)}
{t('step.round', { round: step.round })}
<span className="text-txt-faint">· {statusLabel}</span>
<span className="ml-auto">{t(`risk.${step.risk}`)}</span>
</div>
<code className="block break-all text-xs text-content-secondary">$ {step.command}</code>
{step.output && (
<pre className="border-dark-700/40 mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-words border-t pt-2 text-[11px] text-txt-muted">
{step.output}
</pre>
)}
</div>
)
}