chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+93 -73
View File
@@ -7,7 +7,6 @@ import {
Loader2,
Plus,
Send,
ShieldAlert,
Sparkles,
Square,
TerminalSquare,
@@ -31,7 +30,7 @@ import Button from '../common/Button'
import { useSettingsStore } from '../../stores/settingsStore'
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
import i18n from '../../i18n'
import { translateErrorMessage } from '../../i18n/format'
import { isSshExecCancelled, translateErrorMessage } from '../../i18n/format'
export interface TerminalAiContext {
selection: string
@@ -64,9 +63,10 @@ interface ThreadMessage {
steps?: AgentStep[]
}
const MAX_AGENT_STEPS = 8
const MAX_TOOL_OUTPUT_CHARS = 12_000
const MAX_AGENT_CONTEXT_CHARS = 48_000
const MAX_AGENT_STEPS = 12
const MAX_TOOL_OUTPUT_CHARS = 8_000
const MAX_SUMMARIZE_CHARS = 6_000
const MAX_AGENT_CONTEXT_CHARS = 32_000
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'relaxed']
function truncateMiddle(value: string, limit: number): string {
@@ -80,6 +80,27 @@ function messageId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
}
/** Collapse "DeepSeek · DeepSeek V4 Pro" when the model label already names the vendor. */
function modelSubtitle(providerName: string, modelLabel: string): string {
const provider = providerName.trim()
const model = modelLabel.trim()
if (!provider) return model
if (!model) return provider
if (model.toLowerCase().startsWith(provider.toLowerCase())) return model
return `${provider} · ${model}`
}
/** Long command / viewport dumps: keep errors + head/tail instead of the raw wall of text. */
function compactToolOutput(raw: string, limit = MAX_TOOL_OUTPUT_CHARS): string {
const text = raw.trim()
if (!text) return text
const lines = text.split('\n').length
if (text.length > Math.floor(limit / 2) || lines > 80) {
return truncateMiddle(analyzeAgentOutput(text).text, limit)
}
return text
}
function agentPrompt(
task: string,
targetLabel: string,
@@ -91,7 +112,7 @@ function agentPrompt(
const checkpoint =
consecutiveFailures >= 2
? `最近已连续失败 ${consecutiveFailures} 次,必须更换排查路径,不得只换参数重试相同操作。`
: round === 4
: round === Math.floor(MAX_AGENT_STEPS / 2)
? '请检查已有证据是否足够;如果足够应立即收敛,不要为了调用工具而调用。'
: round === MAX_AGENT_STEPS
? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。'
@@ -165,6 +186,7 @@ export default function AiTerminalPanel({
const [running, setRunning] = useState(false)
const [copiedId, setCopiedId] = useState('')
const runIdRef = useRef(0)
const runConnectionIdRef = useRef<string | null>(null)
const sessionAllowRef = useRef(new Set<string>())
const timelineRef = useRef<HTMLDivElement>(null)
const initialSelectionRef = useRef('')
@@ -229,6 +251,8 @@ export default function AiTerminalPanel({
const runId = runIdRef.current + 1
runIdRef.current = runId
const runConnectionId = connectionId
runConnectionIdRef.current = runConnectionId
if (!isFollowUp) {
initialSelectionRef.current = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
}
@@ -351,18 +375,15 @@ export default function AiTerminalPanel({
}
setLiveSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command, { fromAgent: true })
const executed = await window.opsxApi.sshExec(runConnectionId, command, { fromAgent: true })
if (runIdRef.current !== runId) return
if (!executed.success && /已终止/.test(executed.error || '')) {
if (!executed.success && isSshExecCancelled(executed.error)) {
return
}
const rawOutput = executed.success
? executed.result?.trim() || i18n.t('ai:error.execNoOutput')
: i18n.t('ai:error.execFailed', { error: executed.error || i18n.t('ai:error.execUnknown') })
const output =
rawOutput.length > MAX_TOOL_OUTPUT_CHARS / 2
? truncateMiddle(analyzeAgentOutput(rawOutput).text, MAX_TOOL_OUTPUT_CHARS)
: rawOutput
const output = compactToolOutput(rawOutput)
const step: AgentStep = {
round,
command,
@@ -388,6 +409,7 @@ export default function AiTerminalPanel({
}
} finally {
if (runIdRef.current === runId) {
runConnectionIdRef.current = null
setRunning(false)
}
}
@@ -426,7 +448,7 @@ export default function AiTerminalPanel({
model,
action: 'explain',
prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`,
context: truncateMiddle(context, MAX_TOOL_OUTPUT_CHARS),
context: compactToolOutput(context, MAX_SUMMARIZE_CHARS),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
@@ -446,8 +468,10 @@ export default function AiTerminalPanel({
const stopAgent = () => {
runIdRef.current += 1
dismissAgentRisk()
if (connectionId) {
void window.opsxApi.sshExecCancel(connectionId)
const runConnectionId = runConnectionIdRef.current
runConnectionIdRef.current = null
if (runConnectionId) {
void window.opsxApi.sshExecCancel(runConnectionId)
}
setRunning(false)
const snapped = liveStepsRef.current
@@ -470,73 +494,69 @@ export default function AiTerminalPanel({
}
return (
<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" />
<aside className="app-agent-panel">
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2">
<div className="flex items-start gap-2">
<Sparkles className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent-blue" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{t('panel.title')}</div>
<div className="truncate text-[10px] text-txt-faint">
{provider && model
? t('panel.modelConfigured', { provider: provider.name, model: model.label })
: t('panel.modelNotConfigured')}
{' · '}
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
<div className="mt-0.5 text-[10px] leading-snug text-txt-faint">
<div
className="truncate"
title={provider && model ? `${provider.name} · ${model.label}` : t('panel.modelNotConfigured')}
>
{provider && model ? modelSubtitle(provider.name, model.label) : t('panel.modelNotConfigured')}
</div>
<div className="break-all" title={sshReady ? targetLabel : undefined}>
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
</div>
</div>
</div>
<div className="flex items-center gap-0.5" role="radiogroup" aria-label={t('safety.aria')}>
{SAFETY_MODE_ORDER.map((mode) => {
const selected = safetyMode === mode
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`safety.${mode}.label`)}
disabled={running}
title={t(`safety.${mode}.description`)}
onClick={() => void setSetting('agentSafetyMode', mode)}
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
}`}
>
{t(`safety.${mode}.label`)}
</button>
)
})}
<div className="flex flex-shrink-0 items-center gap-0.5">
<div role="radiogroup" aria-label={t('safety.aria')} className="flex items-center">
{SAFETY_MODE_ORDER.map((mode) => {
const selected = safetyMode === mode
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`safety.${mode}.label`)}
disabled={running}
title={t(`safety.${mode}.description`)}
onClick={() => void setSetting('agentSafetyMode', mode)}
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
}`}
>
{t(`safety.${mode}.label`)}
</button>
)
})}
</div>
{(messages.length > 0 || liveSteps.length > 0) && (
<button
type="button"
title={t('panel.newTopic')}
aria-label={t('panel.newTopic')}
disabled={running}
onClick={startNewTopic}
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</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'
}`}
title={t('panel.close')}
aria-label={t('panel.close')}
onClick={onClose}
className="rounded p-1 text-txt-muted hover:bg-dark-700"
>
<ShieldAlert className="h-3.5 w-3.5" />
<X className="h-4 w-4" />
</button>
</div>
<button
type="button"
title={t('panel.newTopic')}
aria-label={t('panel.newTopic')}
disabled={running}
onClick={startNewTopic}
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
>
<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"
>
<X className="h-4 w-4" />
</button>
</div>
</header>