fix(ai): 本会话运行为整会话托管,去掉插入不执行

Always 不再按命令指纹记忆,点一次后本话题内非 critical
命令不再弹确认;移除「插入不执行」按钮与相关 prop。
This commit is contained in:
Logan.Li committed 2026-09-19 00:29:08 +08:00
1 parent 2ca32f3199
commit b18214e96a
11 files changed
+66 -111

No files matched your search

+30 -54
View File
@@ -20,7 +20,6 @@ import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount }
import { writeClipboardText } from '../../platform/clipboard' import { writeClipboardText } from '../../platform/clipboard'
import { analyzeAgentOutput } from '../../utils/agentOutput' import { analyzeAgentOutput } from '../../utils/agentOutput'
import { import {
agentCommandAllowFingerprint,
agentCommandNeedsConfirmation, agentCommandNeedsConfirmation,
assessAgentCommandRisk, assessAgentCommandRisk,
normalizeAgentSafetyMode, normalizeAgentSafetyMode,
@@ -45,8 +44,6 @@ interface AiTerminalPanelProps {
connectionId?: string | null connectionId?: string | null
targetLabel?: string targetLabel?: string
isLocal?: boolean isLocal?: boolean
/** 将命令文本写入当前焦点终端输入行(不带回车);返回 false 表示当前不可写。 */
insertToTerminal?: (text: string) => boolean
} }
interface AgentStep { interface AgentStep {
@@ -176,7 +173,6 @@ export default function AiTerminalPanel({
connectionId = null, connectionId = null,
targetLabel = '当前 SSH 会话', targetLabel = '当前 SSH 会话',
isLocal = false, isLocal = false,
insertToTerminal,
}: AiTerminalPanelProps) { }: AiTerminalPanelProps) {
const { t } = useTranslation('ai') const { t } = useTranslation('ai')
const store = useAiProviderStore() const store = useAiProviderStore()
@@ -197,7 +193,7 @@ export default function AiTerminalPanel({
const [copiedId, setCopiedId] = useState('') const [copiedId, setCopiedId] = useState('')
const runIdRef = useRef(0) const runIdRef = useRef(0)
const runConnectionIdRef = useRef<string | null>(null) const runConnectionIdRef = useRef<string | null>(null)
const sessionAllowRef = useRef(new Set<string>()) const sessionAllowRef = useRef(false)
const timelineRef = useRef<HTMLDivElement>(null) const timelineRef = useRef<HTMLDivElement>(null)
const initialSelectionRef = useRef('') const initialSelectionRef = useRef('')
const liveStepsRef = useRef<AgentStep[]>([]) const liveStepsRef = useRef<AgentStep[]>([])
@@ -230,7 +226,7 @@ export default function AiTerminalPanel({
setPrompt('') setPrompt('')
setQueued(null) setQueued(null)
setCopiedId('') setCopiedId('')
sessionAllowRef.current.clear() sessionAllowRef.current = false
initialSelectionRef.current = '' initialSelectionRef.current = ''
} }
@@ -334,54 +330,34 @@ export default function AiTerminalPanel({
throw new Error(i18n.t('ai:error.criticalBlocked', { command })) throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
} }
const proposed: AgentStep = { round, command, risk, status: 'running' } const proposed: AgentStep = { round, command, risk, status: 'running' }
if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll)) { if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll) && !sessionAllowRef.current) {
const fingerprint = agentCommandAllowFingerprint(command, risk) const awaiting = { ...proposed, status: 'awaiting' as const }
if (!sessionAllowRef.current.has(fingerprint)) { setLiveSteps([...completedSteps, awaiting])
const awaiting = { ...proposed, status: 'awaiting' as const } const choice = await confirmAgentRisk({
setLiveSteps([...completedSteps, awaiting]) title: t('step.confirmTitle', { round, risk: t(`risk.${risk}`) }),
const choice = await confirmAgentRisk({ message: t('step.confirmMessage', {
title: t('step.confirmTitle', { round, risk: t(`risk.${risk}`) }), target: targetLabel,
message: t('step.confirmMessage', { mode: t(`safety.${runSafetyMode}.label`),
target: targetLabel, command,
mode: t(`safety.${runSafetyMode}.label`), }),
command, danger: risk === 'high',
}), })
danger: risk === 'high', if (runIdRef.current !== runId) return
}) if (choice === 'reject') {
if (runIdRef.current !== runId) return setLiveSteps([])
if (choice === 'insert') { setMessages((prev) => [
const inserted = insertToTerminal?.(command) || false ...prev,
if (!inserted) { {
await writeClipboardText(command) id: messageId(),
} role: 'assistant',
setLiveSteps([]) content: t('step.rejected', { round }),
setMessages((prev) => [ steps: [...completedSteps],
...prev, },
{ ])
id: messageId(), return
role: 'assistant', }
content: inserted ? t('step.inserted', { round }) : t('step.insertedClipboard', { round }), if (choice === 'always') {
steps: [...completedSteps], sessionAllowRef.current = true
},
])
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)
}
} }
} }
+2 -5
View File
@@ -31,8 +31,8 @@ export interface ConfirmOptions {
cancelText?: string cancelText?: string
} }
/** Agent 高风险确认:Reject / Insert(只插入不执行)/ Once / Always(Always 仅会话内存) */ /** Agent 高风险确认:Reject / Once / Always(Always 对本话题后续命令生效,仅内存) */
export type AgentRiskChoice = 'reject' | 'once' | 'always' | 'insert' export type AgentRiskChoice = 'reject' | 'once' | 'always'
export interface AgentRiskConfirmOptions { export interface AgentRiskConfirmOptions {
title: string title: string
@@ -173,9 +173,6 @@ export function useConfirmDialog() {
<Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}> <Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}>
{t('ai:confirm.reject')} {t('ai:confirm.reject')}
</Button> </Button>
<Button variant="secondary" size="sm" onClick={() => resolveAgentRisk('insert')}>
{t('ai:confirm.insert')}
</Button>
<Button <Button
variant={agentRisk.danger ? 'danger' : 'primary'} variant={agentRisk.danger ? 'danger' : 'primary'}
size="sm" size="sm"
@@ -1361,7 +1361,6 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
connectionId={connectionState === 'connected' ? connectionIdRef.current : null} connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
targetLabel={currentTab?.title || t('toolbar.targetSshSession')} targetLabel={currentTab?.title || t('toolbar.targetSshSession')}
isLocal={false} isLocal={false}
insertToTerminal={(text) => paneRuntimesRef.current.get(focusedPaneId)?.insertText(text) ?? false}
/> />
)} )}
</div> </div>
+3 -6
View File
@@ -61,16 +61,13 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}", "confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "Step {{round}} command rejected.", "rejected": "Step {{round}} command rejected.",
"terminated": "Terminated", "terminated": "Terminated",
"stoppedMessage": "Stopped. Interrupt requested for the running remote command.", "stoppedMessage": "Stopped. Interrupt requested for the running remote command."
"inserted": "Round {{round}} command was inserted into the terminal input line without running; review it and press Enter yourself.",
"insertedClipboard": "Round {{round}} command was copied to the clipboard (terminal not writable); paste, review, then run."
}, },
"confirm": { "confirm": {
"reject": "Reject", "reject": "Reject",
"once": "Once", "once": "Once",
"always": "Always", "always": "Run for session",
"hint": "Enter · Once Esc · Reject Always · skip similar confirmations this session", "hint": "Enter · once Esc · reject Run for session · no more prompts in this task (critical still blocked)"
"insert": "Insert only"
}, },
"error": { "error": {
"noProvider": "Configure an AI provider in Settings → AI first", "noProvider": "Configure an AI provider in Settings → AI first",
+3 -6
View File
@@ -54,16 +54,13 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}", "confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "ステップ {{round}} のコマンドは拒否されました。", "rejected": "ステップ {{round}} のコマンドは拒否されました。",
"terminated": "終了しました", "terminated": "終了しました",
"stoppedMessage": "停止しました。実行中のリモートコマンドに中断要求を送信しました。", "stoppedMessage": "停止しました。実行中のリモートコマンドに中断要求を送信しました。"
"inserted": "ラウンド {{round}} のコマンドを入力行に挿入しました(未実行)。確認してから Enter で実行してください。",
"insertedClipboard": "ラウンド {{round}} のコマンドをクリップボードにコピーしました(端末に書き込めません)。貼り付けて確認してから実行してください。"
}, },
"confirm": { "confirm": {
"reject": "拒否", "reject": "拒否",
"once": "1 回のみ", "once": "1 回のみ",
"always": "常に", "always": "このセッションで実行",
"hint": "Enter · 1 回のみ Esc · 拒否 常に · このセッションで同種の確認をスキップ", "hint": "Enter · 一度実行 Esc · 拒否 このセッションで実行 · 以降このタスクでは確認しない(危険コマンドは遮断)"
"insert": "挿入のみ"
}, },
"error": { "error": {
"noProvider": "先に「設定 → AI」で AI プロバイダーを設定してください", "noProvider": "先に「設定 → AI」で AI プロバイダーを設定してください",
+3 -6
View File
@@ -54,16 +54,13 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}", "confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "{{round}}단계 명령이 거부되었습니다.", "rejected": "{{round}}단계 명령이 거부되었습니다.",
"terminated": "종료됨", "terminated": "종료됨",
"stoppedMessage": "중지됨. 실행 중인 원격 명령에 대해 인터럽트가 요청되었습니다.", "stoppedMessage": "중지됨. 실행 중인 원격 명령에 대해 인터럽트가 요청되었습니다."
"inserted": "{{round}}번째 명령을 입력 줄에 삽입했지만 실행하지 않았습니다. 확인 후 직접 Enter를 눌러 실행하세요.",
"insertedClipboard": "{{round}}번째 명령을 클립보드에 복사했습니다(터미널에 쓸 수 없음). 붙여넣고 확인한 뒤 실행하세요."
}, },
"confirm": { "confirm": {
"reject": "거부", "reject": "거부",
"once": "1회", "once": "1회",
"always": "항상", "always": "이 세션에서 실행",
"hint": "Enter · 1회 Esc · 거부 항상 · 이 세션에서 유사한 확인 건너뛰기", "hint": "Enter · 한 번 실행 Esc · 거부 이 세션에서 실행 · 이후 이 작업에서는 확인 안 함(위험 명령은 차단)"
"insert": "삽입만"
}, },
"error": { "error": {
"noProvider": "먼저 설정 → AI에서 AI 제공자를 구성하세요", "noProvider": "먼저 설정 → AI에서 AI 제공자를 구성하세요",
+3 -6
View File
@@ -54,16 +54,13 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}", "confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "Команда шага {{round}} отклонена.", "rejected": "Команда шага {{round}} отклонена.",
"terminated": "Прервано", "terminated": "Прервано",
"stoppedMessage": "Остановлено. Запрошено прерывание выполняемой удалённой команды.", "stoppedMessage": "Остановлено. Запрошено прерывание выполняемой удалённой команды."
"inserted": "Команда раунда {{round}} вставлена в строку ввода терминала без выполнения; проверьте и нажмите Enter сами.",
"insertedClipboard": "Команда раунда {{round}} скопирована в буфер обмена (терминал недоступен для записи); вставьте, проверьте и выполните."
}, },
"confirm": { "confirm": {
"reject": "Отклонить", "reject": "Отклонить",
"once": "Один раз", "once": "Один раз",
"always": "Всегда", "always": "На сессию",
"hint": "Enter · Один раз Esc · Отклонить Всегда · пропускать похожие подтверждения в этом сеансе", "hint": "Enter · один раз Esc · отклонить На сессию · без подтверждений до конца задачи (критичные всё ещё блокируются)"
"insert": "Только вставить"
}, },
"error": { "error": {
"noProvider": "Сначала настройте AI-провайдера в Настройки → AI", "noProvider": "Сначала настройте AI-провайдера в Настройки → AI",
+3 -6
View File
@@ -54,16 +54,13 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}", "confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "Lệnh bước {{round}} đã bị từ chối.", "rejected": "Lệnh bước {{round}} đã bị từ chối.",
"terminated": "Đã chấm dứt", "terminated": "Đã chấm dứt",
"stoppedMessage": "Đã dừng. Đã yêu cầu ngắt cho lệnh từ xa đang chạy.", "stoppedMessage": "Đã dừng. Đã yêu cầu ngắt cho lệnh từ xa đang chạy."
"inserted": "Lệnh vòng {{round}} đã được chèn vào dòng nhập của terminal, chưa chạy; hãy kiểm tra rồi tự nhấn Enter.",
"insertedClipboard": "Lệnh vòng {{round}} đã được sao chép vào clipboard (terminal không ghi được); dán, kiểm tra rồi mới chạy."
}, },
"confirm": { "confirm": {
"reject": "Từ chối", "reject": "Từ chối",
"once": "Một lần", "once": "Một lần",
"always": "Luôn luôn", "always": "Chạy trong phiên",
"hint": "Enter · Một lần Esc · Từ chối Luôn luôn · bỏ qua các xác nhận tương tự trong phiên này", "hint": "Enter · một lần Esc · từ chối Chạy trong phiên · không hỏi lại trong tác vụ này (lệnh nguy hiểm vẫn chặn)"
"insert": "Chỉ chèn"
}, },
"error": { "error": {
"noProvider": "Cấu hình nhà cung cấp AI trong Cài đặt → AI trước tiên", "noProvider": "Cấu hình nhà cung cấp AI trong Cài đặt → AI trước tiên",
+3 -6
View File
@@ -61,16 +61,13 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}", "confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "已拒绝第 {{round}} 步命令。", "rejected": "已拒绝第 {{round}} 步命令。",
"terminated": "已终止", "terminated": "已终止",
"stoppedMessage": "已终止。正在执行的远端命令已请求中断。", "stoppedMessage": "已终止。正在执行的远端命令已请求中断。"
"inserted": "第 {{round}} 轮命令已插入终端输入行,未执行;请检查后自行回车运行。",
"insertedClipboard": "第 {{round}} 轮命令已复制到剪贴板(当前终端不可写入);粘贴后请检查再运行。"
}, },
"confirm": { "confirm": {
"reject": "拒绝", "reject": "拒绝",
"once": "执行一次", "once": "执行一次",
"always": "本会话允许", "always": "本会话运行",
"hint": "Enter · 执行一次 Esc · 拒绝 本会话允许 · 同类命令本会话不再确认", "hint": "Enter · 执行一次 Esc · 拒绝 本会话运行 · 本任务内其余命令不再确认(危险命令仍拦截)"
"insert": "插入不执行"
}, },
"error": { "error": {
"noProvider": "请先在设置 → 大模型里配置供应商", "noProvider": "请先在设置 → 大模型里配置供应商",
+3 -6
View File
@@ -54,16 +54,13 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}", "confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "已拒絕第 {{round}} 步命令。", "rejected": "已拒絕第 {{round}} 步命令。",
"terminated": "已終止", "terminated": "已終止",
"stoppedMessage": "已終止。正在執行的遠端命令已請求中斷。", "stoppedMessage": "已終止。正在執行的遠端命令已請求中斷。"
"inserted": "第 {{round}} 輪命令已插入終端機輸入行,未執行;請檢查後自行按 Enter 執行。",
"insertedClipboard": "第 {{round}} 輪命令已複製到剪貼簿(目前終端機不可寫入);貼上後請檢查再執行。"
}, },
"confirm": { "confirm": {
"reject": "拒絕", "reject": "拒絕",
"once": "執行一次", "once": "執行一次",
"always": "本連線允許", "always": "本工作階段執行",
"hint": "Enter · 執行一次 Esc · 拒絕 本連線允許 · 同類命令本連線不再確認", "hint": "Enter · 執行一次 Esc · 拒絕 本工作階段執行 · 本任務內其餘命令不再確認(危險命令仍攔截)"
"insert": "插入不執行"
}, },
"error": { "error": {
"noProvider": "請先在設定 → 大模型裡設定供應商", "noProvider": "請先在設定 → 大模型裡設定供應商",
+13 -9
View File
@@ -129,26 +129,27 @@ describe('terminal Agent CLI panel', () => {
const dialog = await screen.findByRole('dialog') const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText(/宽松/)).toBeVisible() expect(within(dialog).getByText(/宽松/)).toBeVisible()
expect(within(dialog).getByText(/systemctl restart nginx/)).toBeVisible() expect(within(dialog).getByText(/systemctl restart nginx/)).toBeVisible()
expect(within(dialog).queryByRole('button', { name: '插入不执行' })).not.toBeInTheDocument()
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled() expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: '执行一次' })) fireEvent.click(within(dialog).getByRole('button', { name: '执行一次' }))
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(1)) await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(1))
}) })
it('Always skips confirmation for the same command shape in the current panel session', async () => { it('Always auto-runs remaining confirmable commands in the current panel session', async () => {
vi.mocked(mockDesktopApi.aiComplete) vi.mocked(mockDesktopApi.aiComplete)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
success: true, success: true,
text: '```bash\nrm /tmp/tmp.AAA111\n```', text: '```bash\nsudo rmdir /var/ftp\n```',
model: 'test', model: 'test',
redactedCount: 0, redactedCount: 0,
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
success: true, success: true,
text: '```bash\nrm /tmp/tmp.BBB222\n```', text: '```bash\nsystemctl restart nginx\n```',
model: 'test', model: 'test',
redactedCount: 0, redactedCount: 0,
}) })
.mockResolvedValueOnce({ success: true, text: 'FINAL: 清理完成。', model: 'test', redactedCount: 0 }) .mockResolvedValueOnce({ success: true, text: 'FINAL: 清理并重启完成。', model: 'test', redactedCount: 0 })
vi.mocked(mockDesktopApi.sshExec) vi.mocked(mockDesktopApi.sshExec)
.mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 }) .mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 })
.mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 }) .mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 })
@@ -162,20 +163,23 @@ describe('terminal Agent CLI panel', () => {
/> />
) )
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), { fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
target: { value: '清理临时文件' }, target: { value: '清理并重启' },
}) })
fireEvent.click(screen.getByRole('button', { name: '发送' })) fireEvent.click(screen.getByRole('button', { name: '发送' }))
const dialog = await screen.findByRole('dialog') const dialog = await screen.findByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: '本会话允许' })) expect(within(dialog).queryByRole('button', { name: '插入不执行' })).not.toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: '本会话运行' }))
await waitFor(() => await waitFor(() =>
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'rm /tmp/tmp.AAA111', { fromAgent: true }) expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'sudo rmdir /var/ftp', { fromAgent: true })
) )
await waitFor(() => await waitFor(() =>
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'rm /tmp/tmp.BBB222', { fromAgent: true }) expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'systemctl restart nginx', {
fromAgent: true,
})
) )
expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(await screen.findByText('清理完成。')).toBeVisible() expect(await screen.findByText('清理并重启完成。')).toBeVisible()
}) })
it('lets strict mode require confirmation from medium risk upward', async () => { it('lets strict mode require confirmation from medium risk upward', async () => {