feat: release OpsXShell 2.1.1

This commit is contained in:
Logan.Li committed 2026-07-31 01:37:01 +08:00
1 parent a3dba0c6ed
commit 494ef6b9d0
37 files changed
+2039 -370

No files matched your search

+268 -196
View File
@@ -1,9 +1,21 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, Clipboard, CornerDownLeft, Loader2, Play, Server, ShieldAlert, Sparkles, X } from 'lucide-react'
import type { AiAssistantAction } from '../../../shared/types/ai.types'
import { useEffect, useRef, useState } from 'react'
import {
Check,
CheckCircle2,
Clipboard,
Loader2,
Play,
Server,
ShieldAlert,
Sparkles,
TerminalSquare,
X,
} from 'lucide-react'
import { useAiProviderStore } from '../../stores/aiProviderStore'
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../utils/aiResponse'
import { writeClipboardText } from '../../platform/clipboard'
import { analyzeAgentOutput } from '../../utils/agentOutput'
import { AGENT_RISK_LABEL, assessAgentCommandRisk, type AgentCommandRisk } from '../../utils/agentCommandRisk'
import { useConfirmDialog } from '../common/ConfirmDialog'
export interface TerminalAiContext {
@@ -13,44 +25,91 @@ export interface TerminalAiContext {
interface AiTerminalPanelProps {
capture: () => TerminalAiContext
insert: (command: string) => void
onClose: () => void
connectionId?: string | null
targetLabel?: string
isLocal?: boolean
}
const ACTIONS: Array<{ id: AiAssistantAction; label: string }> = [
{ id: 'explain', label: '解释' },
{ id: 'command', label: '生成命令' },
{ id: 'diagnose', label: '排障' },
{ id: 'execute', label: '执行任务' },
]
interface AgentStep {
round: number
command: string
output?: string
exitCode?: number
risk: AgentCommandRisk
status: 'awaiting' | 'running' | 'done' | 'error'
}
function commandMayChangeSystem(command: string): boolean {
return /(^|[;&|]\s*)(rm|mv|cp|chmod|chown|kill|pkill|reboot|shutdown|mkfs|dd|systemctl|service|apt|yum|dnf|apk|brew)\b|\bsed\s+-i\b|(^|[^<])>{1,2}[^=]|\bkubectl\s+(delete|apply|patch|replace|scale|rollout)\b/i.test(command)
const MAX_AGENT_STEPS = 8
const MAX_TOOL_OUTPUT_CHARS = 12_000
const MAX_AGENT_CONTEXT_CHARS = 48_000
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)}`
}
function agentPrompt(
task: string,
targetLabel: string,
round: number,
consecutiveFailures: number,
protocolRetry: boolean
): string {
const checkpoint =
consecutiveFailures >= 2
? `最近已连续失败 ${consecutiveFailures} 次,必须更换排查路径,不得只换参数重试相同操作。`
: round === 4
? '请检查已有证据是否足够;如果足够应立即收敛,不要为了调用工具而调用。'
: round === MAX_AGENT_STEPS
? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。'
: ''
return [
`用户任务:${task}`,
`执行目标:当前已连接 SSH 主机「${targetLabel}」。`,
`当前为 Agent 第 ${round}/${MAX_AGENT_STEPS} 轮。`,
'你只有 ssh-exec 一个工具,可执行当前远端 shell 中的单行命令。',
'如果仍需获取信息或执行操作:只返回一个 bash 代码块,块内只能有一行命令。',
'如果任务已完成、已无需命令,或仅根据已有上下文就能回答:以“FINAL:”开头给出简短结论,不要包含代码块。',
'不得声称执行过尚未出现在真实工具结果中的命令。',
protocolRetry ? '上一次回答没有遵循工具协议;这一次必须二选一:唯一单行 bash 代码块,或以 FINAL: 开头的结论。' : '',
checkpoint,
]
.filter(Boolean)
.join('\n')
}
function agentContext(initialSelection: string, steps: AgentStep[]): string {
const parts: string[] = []
if (initialSelection) parts.push(`【用户选中的初始终端内容】\n${initialSelection}`)
for (const step of steps) {
parts.push(
`【ssh-exec 第 ${step.round} 步真实结果】\n$ ${step.command}\n${step.output || '命令无输出'}${
step.exitCode === undefined ? '' : `\n[exit_code=${step.exitCode}]`
}`
)
}
return truncateMiddle(parts.join('\n\n'), MAX_AGENT_CONTEXT_CHARS)
}
export default function AiTerminalPanel({
capture,
insert,
onClose,
connectionId = null,
targetLabel = '当前 SSH 会话',
isLocal = false,
}: AiTerminalPanelProps) {
const store = useAiProviderStore()
const [action, setAction] = useState<AiAssistantAction>('diagnose')
const [prompt, setPrompt] = useState('')
const [includeViewport, setIncludeViewport] = useState(false)
const [result, setResult] = useState('')
const [contextNote, setContextNote] = useState('不会自动发送终端内容')
const [contextNote, setContextNote] = useState('仅在开始时读取当前选中内容')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [running, setRunning] = useState(false)
const [copied, setCopied] = useState(false)
const [toolOutput, setToolOutput] = useState('')
const [pendingCommand, setPendingCommand] = useState('')
const [executing, setExecuting] = useState(false)
const [steps, setSteps] = useState<AgentStep[]>([])
const runIdRef = useRef(0)
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
useEffect(() => {
@@ -59,12 +118,11 @@ export default function AiTerminalPanel({
const provider = store.providers.find((item) => item.id === store.activeProviderId && item.enabled)
const model = provider?.models.find((item) => item.id === provider.defaultModelId)
const blocks = useMemo(() => extractBashBlocks(result), [result])
const insertable = blocks.length === 1 ? singleLineTerminalInsert(blocks[0]) : null
const submit = async () => {
const runAgent = async () => {
setError('')
setResult('')
setSteps([])
if (!provider || !model) {
setError('请先在“设置 → 大模型”添加 Provider 并选择默认模型')
return
@@ -73,92 +131,125 @@ export default function AiTerminalPanel({
setError(`Provider「${provider.name}」尚未保存 API Key`)
return
}
if (!prompt.trim()) {
setError('请输入问题或任务')
const task = prompt.trim()
if (!task) {
setError('请输入要执行的任务')
return
}
if (action === 'execute' && (!connectionId || isLocal)) {
setError('ssh-exec 只作用于当前已连接的远程 SSH 会话')
if (!connectionId || isLocal) {
setError('Agent CLI 只作用于当前已连接的远程 SSH 会话')
return
}
const captured = capture()
const selection = captured.selection.trim()
const context = selection || (includeViewport ? captured.viewport.trim() : '')
const runId = runIdRef.current + 1
runIdRef.current = runId
const selection = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
setContextNote(
selection
? `已发送选中内容 · ${terminalContextLineCount(context)} 行`
: context
? `已发送当前屏幕 · ${terminalContextLineCount(context)} 行`
: '本次未发送终端内容'
? `已附带选中内容 · ${terminalContextLineCount(selection)} 行`
: '未附带终端内容 · Agent 将通过 ssh-exec 自主检查'
)
setLoading(true)
setToolOutput('')
setPendingCommand('')
setRunning(true)
const completedSteps: AgentStep[] = []
const commandCounts = new Map<string, number>()
let redactedCount = 0
let consecutiveFailures = 0
let protocolRetry = false
try {
const executionScope = action === 'execute'
? `\n\n执行目标:当前 SSH 会话「${targetLabel}」。只生成远程 shell 内要执行的命令。远程已有的 kubectl、helm、docker 等工具可像普通命令一样使用。`
: ''
const response = await window.opsxApi.aiComplete({
provider,
model,
action,
prompt: `${prompt.trim()}${executionScope}`,
context,
})
if (!response.success || !response.text) throw new Error(response.error || '模型未返回内容')
setResult(response.text)
if (action === 'execute') {
const commands = extractBashBlocks(response.text)
const command = commands.length === 1 ? singleLineTerminalInsert(commands[0]) : null
for (let round = 1; round <= MAX_AGENT_STEPS; round += 1) {
const completion = await window.opsxApi.aiComplete({
provider,
model,
action: 'execute',
prompt: agentPrompt(task, targetLabel, round, consecutiveFailures, protocolRetry),
context: agentContext(selection, completedSteps),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
throw new Error(completion.error || '模型未返回内容')
}
redactedCount += completion.redactedCount || 0
const blocks = extractBashBlocks(completion.text)
if (blocks.length === 0) {
if (!/^\s*FINAL\s*:/i.test(completion.text)) {
if (!protocolRetry) {
protocolRetry = true
continue
}
throw new Error('模型连续两次未遵循 Agent 工具协议,已停止以避免误判')
}
const finalAnswer = completion.text.replace(/^\s*FINAL\s*:\s*/i, '').trim()
if (!finalAnswer) throw new Error('模型未返回可用的最终结论')
setResult(finalAnswer)
return
}
if (blocks.length !== 1) {
throw new Error('模型一次返回了多个命令,已拒绝执行')
}
const command = singleLineTerminalInsert(blocks[0])
if (!command) {
setError('模型没有返回唯一的单行命令,已拒绝进入执行流程')
} else {
setPendingCommand(command)
throw new Error('模型未返回唯一的单行命令,已拒绝执行')
}
protocolRetry = false
const repeated = (commandCounts.get(command) || 0) + 1
commandCounts.set(command, repeated)
if (repeated > 2) {
throw new Error(`检测到重复命令循环,已在执行前停止:${command}`)
}
const risk = assessAgentCommandRisk(command)
if (risk === 'critical') {
throw new Error(`命令命中破坏性操作硬红线,不提供确认放行:${command}`)
}
const awaiting: AgentStep = { round, command, risk, status: 'awaiting' }
setSteps([...completedSteps, awaiting])
const approved = await confirm({
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
message: `目标:SSH · ${targetLabel}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
danger: risk === 'high',
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
})
if (!approved) {
setSteps([...completedSteps])
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
return
}
setSteps([...completedSteps, { ...awaiting, status: 'running' }])
const executed = await window.opsxApi.sshExec(connectionId, command)
if (runIdRef.current !== runId) return
const rawOutput = executed.success
? executed.result?.trim() || '命令执行完成,无输出'
: `ssh-exec 失败:${executed.error || '未知错误'}`
const output =
rawOutput.length > MAX_TOOL_OUTPUT_CHARS / 2
? truncateMiddle(analyzeAgentOutput(rawOutput).text, MAX_TOOL_OUTPUT_CHARS)
: rawOutput
const step: AgentStep = {
round,
command,
output,
exitCode: executed.exitCode,
risk,
status: executed.success && (executed.exitCode === undefined || executed.exitCode === 0) ? 'done' : 'error',
}
completedSteps.push(step)
consecutiveFailures = step.status === 'error' ? consecutiveFailures + 1 : 0
setSteps([...completedSteps])
}
throw new Error(`已达到 ${MAX_AGENT_STEPS} 步上限,任务尚未收敛,请缩小任务范围后重试`)
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
} finally {
if (runIdRef.current === runId) {
setRunning(false)
if (redactedCount > 0) {
setContextNote((current) => `${current} · 发送前本地脱敏 ${redactedCount} 处`)
}
}
if (response.redactedCount) setContextNote((current) => `${current} · 本地脱敏 ${response.redactedCount} 处`)
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
} finally {
setLoading(false)
}
}
const executeOnce = async () => {
if (!provider || !model || !pendingCommand || action !== 'execute') return
const target = `SSH · ${targetLabel}`
const highRisk = commandMayChangeSystem(pendingCommand)
const approved = await confirm({
title: highRisk ? '确认执行可能修改系统的命令' : '确认执行一次',
message: `目标:${target}\n\n命令:${pendingCommand}\n\n该操作只执行一次,不会保存到命令历史。`,
danger: highRisk,
confirmText: highRisk ? '确认风险并执行' : '执行一次',
})
if (!approved) return
setExecuting(true)
setError('')
setToolOutput('')
try {
const response = await window.opsxApi.sshExec(connectionId || '', pendingCommand)
if (!response.success) throw new Error(response.error || '工具执行失败')
const output = response.result?.trim() || '命令执行成功,无输出'
setToolOutput(output)
const executedCommand = pendingCommand
setPendingCommand('')
const analysis = await window.opsxApi.aiComplete({
provider,
model,
action: 'diagnose',
prompt: `用户原任务:${prompt.trim()}\n已通过 ssh-exec 执行一次命令。根据真实结果给出简短结论;不要声称执行了其他操作。`,
context: `$ ${executedCommand}\n${output}`,
})
if (analysis.success && analysis.text) setResult(analysis.text)
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
} finally {
setExecuting(false)
}
}
@@ -169,13 +260,13 @@ export default function AiTerminalPanel({
}
return (
<aside className="border-dark-700/50 bg-dark-850 flex w-[370px] flex-shrink-0 flex-col border-l">
<aside className="border-dark-700/50 bg-dark-850 flex w-[390px] 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">终端助手</div>
<div className="text-sm font-medium text-content-primary">Agent CLI</div>
<div className="truncate text-[10px] text-txt-faint">
{provider && model ? `${provider.name} · ${model.label}` : '尚未配置模型'}
{provider && model ? `LLM: ${provider.name} · ${model.label}` : '尚未配置模型'}
</div>
</div>
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
@@ -184,67 +275,45 @@ export default function AiTerminalPanel({
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-3">
<div className="bg-dark-900/60 flex gap-1 rounded-lg p-1">
{ACTIONS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
setAction(item.id)
setResult('')
setToolOutput('')
setPendingCommand('')
setError('')
}}
className={`flex-1 rounded-md px-2 py-1.5 text-xs ${action === item.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-muted hover:text-content-secondary'}`}
>
{item.label}
</button>
))}
</div>
{action === 'execute' && (
<div className="border-dark-700/40 bg-dark-900/30 space-y-2 rounded-lg border p-2.5">
<div className="flex items-center gap-1.5 truncate text-[11px] text-content-secondary">
<Server className="h-3.5 w-3.5 flex-shrink-0" /> 目标:{targetLabel}
</div>
<p className="text-[10px] leading-relaxed text-txt-faint">AI 只准备一条远程命令;固定复用当前 SSH 连接,每次真实执行都需要你确认。</p>
<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 通过当前连接执行、观察再决定下一步;每条真实命令都需要你确认。
</p>
</div>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={4}
placeholder="例如:分析这段报错,并给出最少的只读检查命令"
className="ui-input-sm resize-none leading-relaxed"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
if (!running) void runAgent()
}
}}
rows={5}
disabled={running}
placeholder="输入任务描述,回车发送(Shift+回车换行)"
className="ui-input-sm min-h-24 resize-none leading-relaxed disabled:opacity-70"
/>
<label className="border-dark-700/40 bg-dark-900/30 flex items-start gap-2 rounded-lg border p-2.5 text-xs text-txt-muted">
<input
type="checkbox"
checked={includeViewport}
onChange={(event) => setIncludeViewport(event.target.checked)}
className="mt-0.5"
/>
<span>
<span className="block text-content-secondary">无选区时附带当前屏幕</span>
<span className="mt-0.5 block text-[10px] text-txt-faint">
有选中内容时始终只发送选区;默认不读取屏幕。
</span>
</span>
</label>
<button
type="button"
onClick={() => void submit()}
disabled={loading}
className="ui-btn-primary flex w-full items-center justify-center gap-1.5"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}{' '}
{loading ? '处理中...' : action === 'execute' ? '准备执行方案' : '生成建议'}
</button>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-txt-faint">Shift+回车换行 · 回车发送</span>
<button
type="button"
onClick={() => void runAgent()}
disabled={running}
className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
{running ? '执行中…' : '执行'}
</button>
</div>
<div className="text-[10px] text-txt-faint">{contextNote}</div>
{error && (
<div
role="alert"
@@ -254,58 +323,61 @@ export default function AiTerminalPanel({
</div>
)}
{result && (
<div className="space-y-2">
<div className="border-dark-700/50 bg-dark-900/55 whitespace-pre-wrap break-words rounded-lg border p-3 font-mono text-xs leading-relaxed text-content-secondary">
{result}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void copyResult()}
className="ui-btn-secondary flex items-center gap-1.5"
{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"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Clipboard className="h-3.5 w-3.5" />}{' '}
{copied ? '已复制' : '复制结果'}
</button>
{action !== 'execute' && insertable && (
<button
type="button"
onClick={() => insert(insertable)}
className="ui-btn-primary flex items-center gap-1.5"
>
<CornerDownLeft className="h-3.5 w-3.5" />
填入终端
</button>
)}
</div>
{action !== 'execute' && blocks.length > 0 && !insertable && (
<p className="text-[10px] text-amber-300">多行脚本仅允许复制,不提供填入按钮。</p>
)}
{action === 'execute' && pendingCommand && (
<div className={`rounded-lg border p-2.5 ${commandMayChangeSystem(pendingCommand) ? 'border-amber-500/30 bg-amber-500/10' : 'border-dark-700/50 bg-dark-900/40'}`}>
<div className="mb-1 text-[10px] text-txt-faint">待执行 · SSH {targetLabel}</div>
<code className="block break-all text-xs text-content-secondary">{pendingCommand}</code>
<button type="button" onClick={() => void executeOnce()} disabled={executing} className="ui-btn-primary mt-2 flex items-center gap-1.5">
{executing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
{executing ? '执行并分析中...' : '执行一次'}
</button>
<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>
)}
{toolOutput && (
<div className="space-y-1">
<div className="text-[10px] text-emerald-400">真实工具输出</div>
<pre className="border-dark-700/50 bg-black/25 max-h-52 overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3 text-[11px] text-content-secondary">{toolOutput}</pre>
{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>
)}
</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" />
ssh-exec 只复用当前连接;每次执行均需确认,关闭面板即清空内容,不保存命令历史。
最多 {MAX_AGENT_STEPS} 步,重复命令自动拦截;命令、AI 对话和工具输出只存在于当前面板内存。
</footer>
{ConfirmDialogComponent}
</aside>
+4 -3
View File
@@ -45,7 +45,7 @@ const TAB_LIST: TabMeta[] = [
{ id: 'general', label: '工作区', description: '布局与资源', icon: Monitor },
{ id: 'appearance', label: '外观', description: '主题与终端配色', icon: Palette },
{ id: 'terminal', label: '终端', description: '字体与配色', icon: Terminal },
{ id: 'ai', label: '大模型', description: 'Provider 与模型', icon: Sparkles },
{ id: 'ai', label: '大模型', description: 'Provider、模型与测试', icon: Sparkles },
{ id: 'shortcuts', label: '快捷键', description: '键盘快捷键', icon: Keyboard },
{ id: 'ssh', label: 'SSH', description: '连接(高级)', icon: Shield },
{ id: 'security', label: '安全', description: '加密与隐私', icon: Database },
@@ -682,10 +682,11 @@ export default function SettingsView() {
return (
<button
key={tab.id}
data-testid={`settings-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
>
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{tab.label}</div>
<div className={`truncate text-[11px] ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
+67 -35
View File
@@ -1,7 +1,44 @@
import { memo } from 'react'
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle, Clock3 } from 'lucide-react'
import type { ConnectionState } from '../../stores/connection'
export interface ShellClosedOverlayProps {
reason: string
idleTimeout: boolean
restarting?: boolean
onReopen: () => void
}
export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, onReopen }: ShellClosedOverlayProps) {
return (
<div className="bg-dark-900/92 absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-5 text-center">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border">
{idleTimeout ? (
<Clock3 className="h-6 w-6 text-accent-blue" />
) : (
<TerminalIcon className="h-6 w-6 text-accent-blue" />
)}
</div>
<div className="mb-1.5 text-sm font-medium text-content-primary">
{idleTimeout ? '空闲超时,Shell 已退出' : 'Shell 已退出'}
</div>
<div className="mb-2 text-xs leading-relaxed text-txt-muted">{reason}</div>
<div className="mb-4 text-[11px] text-emerald-400">SSH 连接仍可用,其他分屏、SFTP 和隧道不受影响</div>
<button
type="button"
disabled={restarting}
onClick={onReopen}
className="inline-flex items-center gap-2 rounded-lg bg-accent-blue px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-accent-blue-hover disabled:opacity-60"
>
<RefreshCw className={`h-3.5 w-3.5 ${restarting ? 'animate-spin' : ''}`} />
{restarting ? '正在打开…' : '重新打开 Shell'}
</button>
</div>
</div>
)
}
export interface TerminalOverlayProps {
connectionState: ConnectionState
statusMessage: string
@@ -21,29 +58,26 @@ function TerminalOverlay({
onReconnect,
onResume,
}: TerminalOverlayProps) {
const showDisconnectBanner =
connectionState === 'disconnected' && needsReconnect
const showDisconnectBanner = connectionState === 'disconnected' && needsReconnect
return (
<>
{showDisconnectBanner && (
<div className="absolute inset-0 bg-dark-900/92 backdrop-blur-[2px] flex items-center justify-center z-10">
<div className="text-center max-w-sm px-6">
<div className="w-14 h-14 bg-accent-blue/15 border border-accent-blue/25 rounded-2xl flex items-center justify-center mx-auto mb-4">
<WifiOff className="w-7 h-7 text-accent-blue" />
<div className="bg-dark-900/92 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-6 text-center">
<div className="bg-accent-blue/15 border-accent-blue/25 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<WifiOff className="h-7 w-7 text-accent-blue" />
</div>
<div className="text-content-primary mb-1.5 text-base font-medium">连接已断开</div>
<div className="text-txt-muted text-sm mb-6 leading-relaxed">
{statusMessage?.trim()
? statusMessage
: 'SSH 会话已结束或网络中断。点击下方一键重新连接。'}
<div className="mb-1.5 text-base font-medium text-content-primary">连接已断开</div>
<div className="mb-6 text-sm leading-relaxed text-txt-muted">
{statusMessage?.trim() ? statusMessage : 'SSH 会话已结束或网络中断。点击下方一键重新连接。'}
</div>
<button
type="button"
onClick={onReconnect}
className="inline-flex items-center gap-2 px-6 py-2.5 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-xl transition-colors text-sm font-medium shadow-sm shadow-accent-blue/20"
className="shadow-accent-blue/20 inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-blue-hover"
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className="h-4 w-4" />
重新连接
</button>
</div>
@@ -51,24 +85,22 @@ function TerminalOverlay({
)}
{showSuspendedOverlay && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
<div className="text-center max-w-sm px-6">
<div className="text-yellow-400 mb-2 text-base font-medium">连接已挂起</div>
<div className="text-txt-muted text-sm mb-5">
长时间未操作,连接已进入挂起以节省资源
</div>
<div className="bg-dark-900/90 absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-sm px-6 text-center">
<div className="mb-2 text-base font-medium text-yellow-400">连接已挂起</div>
<div className="mb-5 text-sm text-txt-muted">长时间未操作,连接已进入挂起以节省资源</div>
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={onResume}
className="px-4 py-2 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-lg text-sm transition-colors"
className="rounded-lg bg-accent-blue px-4 py-2 text-sm text-white transition-colors hover:bg-accent-blue-hover"
>
恢复连接
</button>
<button
type="button"
onClick={onReconnect}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-content-primary rounded-lg text-sm transition-colors"
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-content-primary transition-colors hover:bg-dark-600"
>
重新连接
</button>
@@ -78,21 +110,21 @@ function TerminalOverlay({
)}
{connectionState === 'error' && (
<div className="absolute inset-0 bg-dark-900/88 flex items-center justify-center z-10">
<div className="text-center max-w-md px-6">
<div className="w-12 h-12 rounded-2xl bg-red-500/15 border border-red-500/25 flex items-center justify-center mx-auto mb-3">
<AlertTriangle className="w-6 h-6 text-red-400" />
<div className="bg-dark-900/88 absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-md px-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/25 bg-red-500/15">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<div className="text-red-300 mb-1.5 font-medium">连接失败</div>
<div className="text-txt-muted text-sm mb-5 leading-relaxed">
<div className="mb-1.5 font-medium text-red-300">连接失败</div>
<div className="mb-5 text-sm leading-relaxed text-txt-muted">
{statusMessage || '请检查主机、端口、用户名与认证方式'}
</div>
<button
type="button"
onClick={onReconnect}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-xl text-sm font-medium transition-colors"
className="inline-flex items-center gap-2 rounded-xl bg-accent-blue px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-blue-hover"
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className="h-4 w-4" />
重试连接
</button>
</div>
@@ -100,16 +132,16 @@ function TerminalOverlay({
)}
{(connectionState === 'connecting' || connectionState === 'reconnecting') && (
<div className="absolute inset-0 bg-dark-900/45 flex items-center justify-center pointer-events-none z-10">
<div className="text-center px-4">
<div className="inline-flex items-center gap-2 text-accent-blue animate-pulse text-sm font-medium">
<TerminalIcon className="w-4 h-4" />
<div className="bg-dark-900/45 pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<div className="px-4 text-center">
<div className="inline-flex animate-pulse items-center gap-2 text-sm font-medium text-accent-blue">
<TerminalIcon className="h-4 w-4" />
{connectionState === 'reconnecting'
? `自动重连中${reconnectAttempt ? ` (${reconnectAttempt})` : ''}…`
: '正在连接…'}
</div>
{statusMessage && connectionState === 'reconnecting' && (
<div className="text-txt-muted text-xs mt-2 max-w-sm">{statusMessage}</div>
<div className="mt-2 max-w-sm text-xs text-txt-muted">{statusMessage}</div>
)}
</div>
</div>
+109 -11
View File
@@ -30,7 +30,7 @@ import { shellPayloadByteLength } from '../../utils/shellData'
import { shellWrite, shellResize, shellCreate, shellKill } from '../../utils/shellIO'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import TerminalOverlay from './TerminalOverlay'
import TerminalOverlay, { ShellClosedOverlay } from './TerminalOverlay'
import { createLogger } from '../../utils/logger'
import type { TerminalViewProps } from './types'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
@@ -38,7 +38,6 @@ import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcu
import { resolveTerminalInputTargets } from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
import { singleLineTerminalInsert } from '../../utils/aiResponse'
const logger = createLogger('TerminalWorkspace')
const MAX_PANES = 4
@@ -46,11 +45,16 @@ const MAX_PANES = 4
interface PaneState {
id: string
shellId: string | null
closed?: {
reason: string
idleTimeout: boolean
restarting?: boolean
}
}
interface PaneRuntime {
capture: () => TerminalAiContext
insert: (command: string) => void
getSize: () => { cols: number; rows: number }
}
function captureTerminal(term: Terminal): TerminalAiContext {
@@ -240,10 +244,7 @@ function PaneTerminal({
})
registerRuntime(paneId, {
capture: () => captureTerminal(term),
insert: (value) => {
const command = singleLineTerminalInsert(value)
if (command) term.paste(command)
},
getSize: () => ({ cols: term.cols, rows: term.rows }),
})
return () => {
@@ -411,6 +412,52 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
}, [connectionIdRef])
// A remote shell can exit while its pooled SSH connection remains healthy
// (for example Bash TMOUT, logout or Ctrl+D). Only mark the matching pane as
// closed; sibling panes, SFTP and tunnels continue using the connection.
useEffect(() => {
const handler = (
_event: unknown,
connectionId: string,
closedShellId: string,
details: { reason: string; idleTimeout: boolean }
) => {
if (!connectionIdRef.current || connectionId !== connectionIdRef.current) return
const pane = panesRef.current.find((item) => item.shellId === closedShellId)
if (!pane) return
getConnectionStore().removeShell(connectionId, closedShellId)
setPanes((current) =>
current.map((item) =>
item.shellId === closedShellId
? {
...item,
shellId: null,
closed: {
reason: details.reason,
idleTimeout: details.idleTimeout,
},
}
: item
)
)
if (shellIdRef.current === closedShellId) {
const fallback = panesRef.current.find((item) => item.shellId && item.shellId !== closedShellId)
shellIdRef.current = fallback?.shellId || null
updateTabConnectionInstance(tabId, {
connectionId,
shellId: fallback?.shellId || undefined,
})
}
}
window.opsxApi?.onShellClosed?.(handler)
return () => {
window.opsxApi?.removeListener?.('ssh:shell-closed', handler as (...args: unknown[]) => void)
}
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
useEffect(() => {
return () => {
if (!currentTab?.isLocal) return
@@ -480,6 +527,48 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
[connectionIdRef, tabId, updateTabConnectionInstance]
)
const reopenPaneShell = useCallback(
async (paneId: string) => {
const connectionId = connectionIdRef.current
if (!connectionId || connectionId === 'local' || connectionState !== 'connected') return
setPanes((current) =>
current.map((pane) =>
pane.id === paneId && pane.closed ? { ...pane, closed: { ...pane.closed, restarting: true } } : pane
)
)
const size = paneRuntimesRef.current.get(paneId)?.getSize() || { cols: 80, rows: 24 }
const preferredId = `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
try {
const result = await shellCreate(false, connectionId, size, preferredId)
if (!result.success || !result.shellId) {
throw new Error(result.error || '创建远端 Shell 失败')
}
getConnectionStore().addShell(connectionId, result.shellId)
setPanes((current) =>
current.map((pane) =>
pane.id === paneId ? { ...pane, shellId: result.shellId || null, closed: undefined } : pane
)
)
if (focusedPaneId === paneId || !shellIdRef.current) {
shellIdRef.current = result.shellId
updateTabConnectionInstance(tabId, { connectionId, shellId: result.shellId })
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
setPanes((current) =>
current.map((pane) =>
pane.id === paneId && pane.closed
? { ...pane, closed: { ...pane.closed, restarting: false, reason } }
: pane
)
)
}
},
[connectionIdRef, connectionState, focusedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
)
const handlePaneInput = useCallback(
(sourcePaneId: string, data: string) => {
const targets = resolveTerminalInputTargets(panesRef.current, sourcePaneId, broadcastInput)
@@ -674,11 +763,13 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
<button
type="button"
title="终端 AI 助手"
aria-label={aiPanelOpen ? '关闭终端 AI 助手' : '打开终端 AI 助手'}
aria-pressed={aiPanelOpen}
data-testid="terminal-ai-button"
onClick={() => setAiPanelOpen((open) => !open)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors ${aiPanelOpen ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'}`}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
>
<Sparkles className="h-3.5 w-3.5" /> AI
<Sparkles className="h-3.5 w-3.5" /> AI 助手
</button>
{broadcastInput && <div className="ui-chip border-accent-orange/30 text-accent-orange">广播中</div>}
<div className="ui-chip tabular-nums">
@@ -706,7 +797,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
tabId={tabId}
isLocal={isLocal}
autoCreateShell={index > 0}
autoCreateShell={index > 0 && !pane.closed}
isFocused={focusedPaneId === pane.id}
isActive={isActive}
onInput={handlePaneInput}
@@ -726,6 +817,14 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
registerRuntime={registerRuntime}
unregisterRuntime={unregisterRuntime}
/>
{pane.closed && (
<ShellClosedOverlay
reason={pane.closed.reason}
idleTimeout={pane.closed.idleTimeout}
restarting={pane.closed.restarting}
onReopen={() => void reopenPaneShell(pane.id)}
/>
)}
{multi && panes.length === 2 && index === 0 && (
<div
onMouseDown={onDividerMouseDown}
@@ -759,7 +858,6 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
{aiPanelOpen && (
<AiTerminalPanel
capture={() => paneRuntimesRef.current.get(focusedPaneId)?.capture() || { selection: '', viewport: '' }}
insert={(command) => paneRuntimesRef.current.get(focusedPaneId)?.insert(command)}
onClose={() => setAiPanelOpen(false)}
connectionId={connectionState === 'connected' && !isLocal ? connectionIdRef.current : null}
targetLabel={currentTab?.title || '当前 SSH 会话'}
+12 -1
View File
@@ -16,7 +16,10 @@ export interface DesktopApiContract {
config: import('../shared/types/ipc.types').SSHConnectRequest
) => Promise<import('../shared/types/ipc.types').SSHConnectResponse>
sshDisconnect: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshExec: (connectionId: string, command: string) => Promise<{ success: boolean; result?: string; error?: string }>
sshExec: (
connectionId: string,
command: string
) => Promise<{ success: boolean; result?: string; exitCode?: number; error?: string }>
sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshCreateShell: (
connectionId: string,
@@ -159,6 +162,14 @@ export interface DesktopApiContract {
onMenuOpenSettings: (callback: () => void) => void
onSshLinkAvailable: (callback: () => void) => void
onShellData: (callback: (event: unknown, connectionId: string, shellId: string, data: Uint8Array) => void) => void
onShellClosed: (
callback: (
event: unknown,
connectionId: string,
shellId: string,
details: { reason: string; idleTimeout: boolean }
) => void
) => void
onConnectionClosed: (callback: (event: unknown, connectionId: string, reason: string) => void) => void
onHostKeyPrompt: (
callback: (
+10
View File
@@ -185,6 +185,16 @@ export const desktopApi: DesktopApi = {
callback as Listener,
(payload) => callback({} as never, payload.connectionId, payload.shellId, new Uint8Array(payload.data) as never)
),
onShellClosed: (callback) =>
addListener<{ connectionId: string; shellId: string; reason: string; idleTimeout: boolean }>(
'ssh:shell-closed',
callback as Listener,
(payload) =>
callback({} as never, payload.connectionId, payload.shellId, {
reason: payload.reason,
idleTimeout: payload.idleTimeout,
})
),
onConnectionClosed: (callback) =>
addListener<{ connectionId: string; reason: string }>('ssh:connection-closed', callback as Listener, (payload) =>
callback({} as never, payload.connectionId, payload.reason)
+180
View File
@@ -0,0 +1,180 @@
export type AgentCommandRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical'
const RISK_WEIGHT: Record<AgentCommandRisk, number> = {
safe: 0,
low: 1,
medium: 2,
high: 3,
critical: 4,
}
const READ_ONLY_PREFIXES = [
'cat ',
'grep ',
'egrep ',
'fgrep ',
'awk ',
'head ',
'tail ',
'less ',
'more ',
'sort ',
'uniq ',
'wc ',
'ps ',
'top',
'df ',
'du ',
'free',
'uptime',
'uname',
'hostname',
'ifconfig',
'ip addr',
'ip link',
'ip route',
'netstat',
'ss ',
'ls',
'll',
'pwd',
'who',
'w',
'last',
'vmstat',
'iostat',
'mpstat',
'sar ',
'lsof',
'find ',
'which ',
'whereis ',
'file ',
'stat ',
'date',
'id',
'whoami',
'groups',
'getent ',
'dmesg',
'systemctl status ',
'systemctl list-units',
'lsblk',
'blkid',
'mount',
'journalctl',
'docker ps',
'docker logs',
'docker inspect',
'kubectl get ',
'kubectl describe ',
'kubectl logs ',
'helm list',
'helm status ',
'git status',
'git log',
]
const CRITICAL_PATTERNS: RegExp[] = [
/\brm\s+[^\n;&|]*-[a-z]*r[a-z]*f[a-z]*\s+(?:--\s+)?\/(?:\s|$|\*)/i,
/\brm\s+[^\n;&|]*-[a-z]*f[a-z]*r[a-z]*\s+(?:--\s+)?\/(?:\s|$|\*)/i,
/\brm\s+[^\n;&|]*-[a-z]*r[a-z]*f[a-z]*\s+\/(?:bin|boot|etc|home|lib|lib64|proc|root|sbin|sys|usr|var)(?:\/|\s|$)/i,
/\bmkfs(?:\.[a-z0-9]+)?\b/i,
/\bdd\s+[^\n;&|]*\bif=\/dev\/(?:zero|random|urandom)\b[^\n;&|]*\bof=\/dev\//i,
/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
/(?:^|[;&|]\s*)>\s*\/dev\/(?:sd[a-z]|hd[a-z]|nvme\d+n\d+)(?:\s|$)/i,
/\bchmod\s+-R\s+(?:000|777)\s+\/(?:\s|$)/i,
/\bkill\s+-9\s+1(?:\s|$)/i,
/\bsystemctl\s+(?:stop|disable)\s+(?:ssh|sshd)(?:\.service)?(?:\s|$)/i,
/\biptables\s+(?:-F|--flush)(?:\s|$)/i,
]
export const AGENT_RISK_LABEL: Record<AgentCommandRisk, string> = {
safe: '安全',
low: '低风险',
medium: '中风险',
high: '高风险',
critical: '禁止执行',
}
function maxRisk(left: AgentCommandRisk, right: AgentCommandRisk): AgentCommandRisk {
return RISK_WEIGHT[left] >= RISK_WEIGHT[right] ? left : right
}
function isCriticalRecursiveRemove(value: string): boolean {
const tokens = value.toLowerCase().replace(/["']/g, '').split(/\s+/).filter(Boolean)
const rmIndex = tokens.findIndex((token) => token === 'rm' || token.endsWith('/rm'))
if (rmIndex < 0) return false
const args = tokens.slice(rmIndex + 1)
const recursive = args.some((token) => token === '--recursive' || /^-[^-]*r/i.test(token))
const force = args.some((token) => token === '--force' || /^-[^-]*f/i.test(token))
if (!recursive || !force) return false
const protectedTargets = new Set([
'/',
'/*',
'/bin',
'/boot',
'/etc',
'/home',
'/lib',
'/lib64',
'/proc',
'/root',
'/sbin',
'/sys',
'/usr',
'/var',
])
return args.some((token) => protectedTargets.has(token.replace(/\/+$/g, '') || '/'))
}
function assessSegment(value: string): AgentCommandRisk {
const segment = value.trim().toLowerCase()
if (!segment) return 'safe'
if (isCriticalRecursiveRemove(segment) || CRITICAL_PATTERNS.some((pattern) => pattern.test(segment))) {
return 'critical'
}
// Redirection and in-place editing mutate state even when the leading tool
// usually looks read-only.
if (/\bsed\s+-[^\s]*i\b/.test(segment) || /(^|[^<>])>{1,2}\s*[^=&]/.test(segment)) {
return segment.startsWith('sudo ') ? 'high' : 'medium'
}
if (/\b(?:shutdown|reboot|halt|poweroff|init\s+[06])\b/.test(segment)) return 'high'
if (/\bsystemctl\s+(?:restart|stop|start|enable|disable|mask|unmask)\b/.test(segment)) return 'high'
if (/\b(?:rm|kill|killall|pkill|userdel|groupdel|fdisk|parted)\b/.test(segment)) return 'high'
if (/\bfind\b[^;&|]*\s-delete(?:\s|$)/.test(segment)) return 'high'
if (/\b(?:apt|apt-get|yum|dnf|apk|brew|pacman)\s+(?:install|remove|purge|upgrade|update)\b/.test(segment)) {
return 'high'
}
if (/\b(?:kubectl|helm)\s+(?:apply|delete|patch|replace|scale|rollout|upgrade|uninstall)\b/.test(segment)) {
return 'high'
}
if (/\b(?:cp|mv|mkdir|rmdir|touch|chmod|chown|chgrp|ln|tar|gzip|gunzip|zip|unzip|rsync|tee)\b/.test(segment)) {
return segment.startsWith('sudo ') ? 'high' : 'medium'
}
const withoutSudo = segment.replace(/^sudo\s+/, '')
if (READ_ONLY_PREFIXES.some((prefix) => withoutSudo === prefix.trim() || withoutSudo.startsWith(prefix))) {
return segment.startsWith('sudo ') ? 'low' : 'safe'
}
return segment.startsWith('sudo ') ? 'medium' : 'low'
}
/**
* Five-level best-effort risk assessment for AI-proposed one-line commands.
* Every non-critical command still requires explicit user confirmation; this
* classifier controls hard denial and communicates risk, never authorization.
*/
export function assessAgentCommandRisk(command: string): AgentCommandRisk {
const normalized = command.replace(/\s+/g, ' ').trim()
if (!normalized) return 'safe'
if (isCriticalRecursiveRemove(normalized) || CRITICAL_PATTERNS.some((pattern) => pattern.test(normalized))) {
return 'critical'
}
let risk: AgentCommandRisk = 'safe'
for (const segment of normalized.split(/&&|\|\||;|\|/g)) {
risk = maxRisk(risk, assessSegment(segment))
}
return risk
}
+55
View File
@@ -0,0 +1,55 @@
const ERROR_KEYWORDS = [
'error',
'fail',
'exception',
'traceback',
'denied',
'permission',
'timeout',
'refused',
'unavailable',
'critical',
'fatal',
'错误',
'失败',
'拒绝',
'超时',
'无法',
'异常',
]
export interface AgentOutputAnalysis {
text: string
totalLines: number
errorLines: number
truncated: boolean
}
/** Deterministic, local analyze_output tool for long SSH results. */
export function analyzeAgentOutput(output: string, focus = ''): AgentOutputAnalysis {
if (!output) {
return { text: '(空输出)', totalLines: 0, errorLines: 0, truncated: false }
}
const originalLines = output.replace(/\r\n/g, '\n').split('\n')
const totalLines = originalLines.length
const truncated = totalLines > 120
const visibleLines = truncated
? [...originalLines.slice(0, 40), `... [已省略 ${totalLines - 80} 行] ...`, ...originalLines.slice(-40)]
: originalLines
const keywords = [focus.toLowerCase(), ...ERROR_KEYWORDS].filter(Boolean)
const errorSamples = visibleLines
.filter((line) => {
const normalized = line.toLowerCase()
return keywords.some((keyword) => normalized.includes(keyword))
})
.slice(0, 20)
const summary = [
`总行数: ${totalLines}${truncated ? '(已截断保留首尾)' : ''}`,
errorSamples.length
? `疑似错误/告警行 (${errorSamples.length}):\n${errorSamples.map((line) => ` ${line.trim()}`).join('\n')}`
: '未发现明显错误关键词',
`\n输出片段:\n${visibleLines.join('\n')}`,
].join('\n')
return { text: summary, totalLines, errorLines: errorSamples.length, truncated }
}