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>