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:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user