chore: 保存 Agent 安全优化前基线

This commit is contained in:
Logan.Li committed 2026-09-02 23:31:38 +08:00
1 parent 08696896f9
commit ff016347cd
116 files changed
+3858 -2485

No files matched your search

+126 -23
View File
@@ -7,9 +7,11 @@ import {
Loader2,
Plus,
Send,
ShieldAlert,
Sparkles,
Square,
TerminalSquare,
TextSelect,
X,
} from 'lucide-react'
import { useAiProviderStore } from '../../stores/aiProviderStore'
@@ -25,6 +27,7 @@ import {
type AgentSafetyMode,
} from '../../utils/agentCommandRisk'
import { useConfirmDialog } from '../common/ConfirmDialog'
import Button from '../common/Button'
import { useSettingsStore } from '../../stores/settingsStore'
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
import i18n from '../../i18n'
@@ -41,6 +44,8 @@ interface AiTerminalPanelProps {
connectionId?: string | null
targetLabel?: string
isLocal?: boolean
/** 将命令文本写入当前焦点终端输入行(不带回车);返回 false 表示当前不可写。 */
insertToTerminal?: (text: string) => boolean
}
interface AgentStep {
@@ -146,12 +151,12 @@ export default function AiTerminalPanel({
connectionId = null,
targetLabel = '当前 SSH 会话',
isLocal = false,
insertToTerminal,
}: AiTerminalPanelProps) {
const { t } = useTranslation('ai')
const store = useAiProviderStore()
const safetyMode = normalizeAgentSafetyMode(
useSettingsStore((state) => state.settings.agentSafetyMode)
)
const safetyMode = normalizeAgentSafetyMode(useSettingsStore((state) => state.settings.agentSafetyMode))
const confirmAllCommands = useSettingsStore((state) => state.settings.agentConfirmAllCommands)
const setSetting = useSettingsStore((state) => state.setSetting)
const [prompt, setPrompt] = useState('')
const [messages, setMessages] = useState<ThreadMessage[]>([])
@@ -231,6 +236,7 @@ export default function AiTerminalPanel({
setRunning(true)
setLiveSteps([])
const runSafetyMode = safetyMode
const runConfirmAll = confirmAllCommands
const completedSteps: AgentStep[] = []
const commandCounts = new Map<string, number>()
@@ -293,7 +299,7 @@ export default function AiTerminalPanel({
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
}
const proposed: AgentStep = { round, command, risk, status: 'running' }
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll)) {
const fingerprint = agentCommandAllowFingerprint(command, risk)
if (!sessionAllowRef.current.has(fingerprint)) {
const awaiting = { ...proposed, status: 'awaiting' as const }
@@ -308,11 +314,33 @@ export default function AiTerminalPanel({
danger: risk === 'high',
})
if (runIdRef.current !== runId) return
if (choice === 'insert') {
const inserted = insertToTerminal?.(command) || false
if (!inserted) {
await writeClipboardText(command)
}
setLiveSteps([])
setMessages((prev) => [
...prev,
{
id: messageId(),
role: 'assistant',
content: inserted ? t('step.inserted', { round }) : t('step.insertedClipboard', { round }),
steps: [...completedSteps],
},
])
return
}
if (choice === 'reject') {
setLiveSteps([])
setMessages((prev) => [
...prev,
{ id: messageId(), role: 'assistant', content: t('step.rejected', { round }), steps: [...completedSteps] },
{
id: messageId(),
role: 'assistant',
content: t('step.rejected', { round }),
steps: [...completedSteps],
},
])
return
}
@@ -323,7 +351,7 @@ export default function AiTerminalPanel({
}
setLiveSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command)
const executed = await window.opsxApi.sshExec(connectionId, command, { fromAgent: true })
if (runIdRef.current !== runId) return
if (!executed.success && /已终止/.test(executed.error || '')) {
return
@@ -371,6 +399,50 @@ export default function AiTerminalPanel({
window.setTimeout(() => setCopiedId(''), 1200)
}
/** ④ 对当前终端可见输出做一次只读总结(不进入 Agent 循环、不执行命令)。 */
const summarizeViewport = async () => {
setError('')
if (!provider || !model) {
setError(t('error.noProvider'))
return
}
if (!provider.hasApiKey && provider.authStyle !== 'none') {
setError(t('error.noApiKey', { provider: provider.name }))
return
}
const context = capture().selection.trim() || capture().viewport.trim()
if (!context) {
setError(t('error.noOutput'))
return
}
setMessages((prev) => [...prev, { id: messageId(), role: 'user', content: t('panel.summarizeRequest') }])
const runId = runIdRef.current + 1
runIdRef.current = runId
setRunning(true)
try {
const completion = await window.opsxApi.aiComplete({
provider,
model,
action: 'explain',
prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`,
context: truncateMiddle(context, MAX_TOOL_OUTPUT_CHARS),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
throw new Error(completion.error || i18n.t('ai:error.modelEmpty'))
}
setMessages((prev) => [...prev, { id: messageId(), role: 'assistant', content: completion.text!.trim() }])
} catch (caught) {
const message = translateErrorMessage(caught instanceof Error ? caught.message : String(caught))
setError(message)
} finally {
if (runIdRef.current === runId) {
setRunning(false)
}
}
}
const stopAgent = () => {
runIdRef.current += 1
dismissAgentRisk()
@@ -398,7 +470,7 @@ export default function AiTerminalPanel({
}
return (
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
<aside className="border-dark-700/50 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l bg-dark-850">
<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" />
@@ -433,6 +505,19 @@ export default function AiTerminalPanel({
</button>
)
})}
<button
type="button"
aria-pressed={confirmAllCommands}
disabled={running}
title={t('safety.confirmAllTitle')}
aria-label={t('safety.confirmAllTitle')}
onClick={() => void setSetting('agentConfirmAllCommands', !confirmAllCommands)}
className={`ml-1 rounded p-1 disabled:opacity-50 ${
confirmAllCommands ? 'text-amber-300' : 'text-txt-faint hover:text-content-secondary'
}`}
>
<ShieldAlert className="h-3.5 w-3.5" />
</button>
</div>
<button
type="button"
@@ -444,7 +529,12 @@ export default function AiTerminalPanel({
>
<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">
<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>
@@ -452,9 +542,7 @@ export default function AiTerminalPanel({
<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>
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">{t('panel.emptyHint')}</p>
)}
{messages.map((message) => (
@@ -504,7 +592,10 @@ export default function AiTerminalPanel({
)}
{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">
<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>
)}
@@ -534,13 +625,29 @@ export default function AiTerminalPanel({
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
</span>
{running ? (
<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 variant="secondary" size="sm" icon={<Square className="h-3.5 w-3.5" />} onClick={stopAgent}>
{t('panel.stop')}
</Button>
) : (
<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>
<>
<Button
variant="ghost"
size="sm"
icon={<TextSelect className="h-3.5 w-3.5" />}
title={t('panel.summarizeTitle')}
onClick={() => void summarizeViewport()}
>
{t('panel.summarize')}
</Button>
<Button
variant="primary"
size="sm"
icon={<Send className="h-3.5 w-3.5" />}
onClick={() => void runAgent()}
>
{t('panel.send')}
</Button>
</>
)}
</div>
<p className="mt-2 text-[10px] text-txt-faint">{t('panel.footerNote')}</p>
@@ -553,11 +660,7 @@ export default function AiTerminalPanel({
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')
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">