chore: 保存 Agent 安全优化前基线
This commit is contained in:
1 parent
08696896f9
commit
ff016347cd
116 files changed
+3858
-2485
No files matched your search
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user