feat(v3): T28 操作审批流(dsh 式 allow-once/deny,fail-closed)
- ToolLoop 增 approval_hook:工具执行前挂起等待用户裁决,拒绝/异常折叠为
失败结果回喂模型(可改道),fail-closed
- 策略 agent.approval_policy:off | dangerous(写/编辑/命令询问,只读放行,默认)| all;
approval_timeout_s 超时自动拒绝(轮询实现,规避 portal 循环下 wait_for 定时器不可靠)
- POST /agent/{id}/approve 裁决端点;approval_request/decided 事件对进 SSE 与审计
- 前端:运行中审批卡(工具名+参数预览+拒绝/允许一次),composer 审批策略 chip
- 测试 +8(策略矩阵/拒绝回喂/允许执行/fail-closed/超时/端点分支),全量 289 passed
This commit is contained in:
@@ -62,6 +62,9 @@
|
||||
:class="['mini-row', ev.ok ? 'ok' : 'err']">
|
||||
{{ ev.ok ? '✓' : '✕' }} {{ ev.name }} · {{ clip(ev.preview, 120) }}
|
||||
</div>
|
||||
<div v-else-if="ev.type === 'delta'" class="mini-row delta-row">
|
||||
{{ clip(ev.text, 200) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
@@ -69,7 +72,22 @@
|
||||
<!-- 历史轮次:步数摘要 -->
|
||||
<div v-else-if="m.toolCalls" class="tool-summary">⚙ 执行了 {{ m.toolCalls }} 步工具调用</div>
|
||||
|
||||
<!-- 审批卡(D9):未裁决时显示操作按钮 -->
|
||||
<div v-if="m.approval && !m.approval.decided" class="approval-card">
|
||||
<div class="ap-head">⏸ 等待审批:<b>{{ m.approval.name }}</b> 请求执行</div>
|
||||
<pre class="ap-args">{{ shortArgs(m.approval.arguments) }}</pre>
|
||||
<div class="ap-actions">
|
||||
<button class="ap-btn reject" @click="decideApproval(m, false)">拒绝</button>
|
||||
<button class="ap-btn allow" @click="decideApproval(m, true)">允许一次</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="m.approval && m.approval.decided" class="ap-done"
|
||||
:class="m.approval.allowed ? 'ok' : 'no'">
|
||||
{{ m.approval.allowed ? '✓ 已允许执行' : '✕ 已拒绝' }}
|
||||
</div>
|
||||
|
||||
<!-- 正文 / 运行中指示 -->
|
||||
<div v-if="m.streamText" class="ai-stream">{{ m.streamText }}<span class="cursor" /></div>
|
||||
<div v-if="m.content" class="ai-text">{{ m.content }}</div>
|
||||
<div v-else-if="m.error" class="ai-error">❌ {{ m.error }}</div>
|
||||
<div v-else-if="running && i === messages.length - 1" class="ai-running">
|
||||
@@ -98,6 +116,12 @@
|
||||
<label class="chip toggle-chip" title="开启后智能体可执行 shell 命令">
|
||||
<input type="checkbox" v-model="allowShell" @change="toggleShell" /> 命令
|
||||
</label>
|
||||
<select v-model="approvalPolicy" class="chip select-chip" title="审批策略:哪些操作需要你确认"
|
||||
@change="savePolicy">
|
||||
<option value="off">审批:关</option>
|
||||
<option value="dangerous">审批:写与命令</option>
|
||||
<option value="all">审批:全部操作</option>
|
||||
</select>
|
||||
<span v-if="errorMsg" class="composer-err">{{ errorMsg }}</span>
|
||||
</div>
|
||||
<div class="composer-main">
|
||||
@@ -153,9 +177,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||
import {
|
||||
startAgent, getAgentStatus, watchAgent,
|
||||
startAgent, getAgentStatus, watchAgent, approveAgent, getConfig, updateConfig,
|
||||
getPool,
|
||||
browseFs, getAgentWorkspaces, openWorkspace, updateConfig,
|
||||
browseFs, getAgentWorkspaces, openWorkspace,
|
||||
listAgentSessions, createAgentSession, getAgentSession, deleteAgentSession, cancelAgent,
|
||||
} from '@/api'
|
||||
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
|
||||
@@ -167,6 +191,8 @@ interface ChatMsg {
|
||||
error?: string | null
|
||||
liveEvents?: AgentEvent[]
|
||||
requestId?: string
|
||||
streamText?: string
|
||||
approval?: { id: string; name: string; arguments?: Record<string, unknown>; decided: boolean; allowed: boolean }
|
||||
}
|
||||
|
||||
const SAMPLES = [
|
||||
@@ -192,6 +218,7 @@ const errorMsg = ref('')
|
||||
const selectedRoot = ref('')
|
||||
const selectedExecutorId = ref('')
|
||||
const allowShell = ref(false)
|
||||
const approvalPolicy = ref('dangerous')
|
||||
const recent = ref<string[]>([])
|
||||
const poolEntries = ref<PoolEntry[]>([])
|
||||
const pickerOpen = ref(false)
|
||||
@@ -306,6 +333,10 @@ async function toggleShell() {
|
||||
try { await updateConfig({ agent: { allow_shell: allowShell.value } } as any) }
|
||||
catch { allowShell.value = !allowShell.value }
|
||||
}
|
||||
async function savePolicy() {
|
||||
try { await updateConfig({ agent: { approval_policy: approvalPolicy.value } } as any) }
|
||||
catch { /* 恢复由刷新处理 */ }
|
||||
}
|
||||
|
||||
// ── 发送 / 停止 ─────────────────────────────────────────────────────────
|
||||
async function send() {
|
||||
@@ -342,6 +373,21 @@ async function send() {
|
||||
_watch.subscribe({
|
||||
onEvent: (ev) => {
|
||||
if (ev.type === 'final') return
|
||||
if (ev.type === 'approval_request') {
|
||||
aiMsg.approval = { id: ev.id!, name: ev.name!, arguments: ev.arguments,
|
||||
decided: false, allowed: false }
|
||||
} else if (ev.type === 'approval_decided') {
|
||||
if (aiMsg.approval && aiMsg.approval.id === ev.id) {
|
||||
aiMsg.approval.decided = true
|
||||
aiMsg.approval.allowed = !!ev.allowed
|
||||
}
|
||||
} else if (ev.type === 'delta') {
|
||||
aiMsg.streamText = (aiMsg.streamText || '') + (ev.text || '')
|
||||
scrollToBottom()
|
||||
return
|
||||
} else if (ev.type === 'tool_call' || ev.type === 'phase' || ev.type === 'message') {
|
||||
aiMsg.streamText = '' // 工具/阶段开始,清空流式文本
|
||||
}
|
||||
aiMsg.liveEvents!.push(ev)
|
||||
scrollToBottom()
|
||||
},
|
||||
@@ -371,13 +417,25 @@ async function stop() {
|
||||
try { await cancelAgent(currentRequestId.value) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function decideApproval(m: ChatMsg, allowed: boolean) {
|
||||
if (!m.approval || !m.requestId) return
|
||||
m.approval.decided = true
|
||||
m.approval.allowed = allowed
|
||||
try { await approveAgent(m.requestId, m.approval.id, allowed) }
|
||||
catch (e: any) {
|
||||
m.approval.decided = false
|
||||
errorMsg.value = e?.response?.data?.detail || e?.message || String(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
refreshSessions()
|
||||
try {
|
||||
const [pool, ws] = await Promise.all([getPool(), getAgentWorkspaces()])
|
||||
const [pool, ws, cfg] = await Promise.all([getPool(), getAgentWorkspaces(), getConfig()])
|
||||
poolEntries.value = pool.entries
|
||||
selectedRoot.value = ws.current || ''
|
||||
recent.value = ws.recent || []
|
||||
if ((cfg as any).agent?.approval_policy) approvalPolicy.value = (cfg as any).agent.approval_policy
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
</script>
|
||||
@@ -563,6 +621,68 @@ onMounted(async () => {
|
||||
.mini-args { color: var(--c-caption); margin-left: 6px; }
|
||||
.tool-summary { font-size: 12px; color: var(--c-caption); margin-bottom: 8px; }
|
||||
|
||||
/* 审批卡(D9) */
|
||||
.approval-card {
|
||||
border: 1px solid var(--c-warn);
|
||||
background: var(--c-warn-soft);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ap-head { font-size: 13px; font-weight: 600; color: var(--c-warn); margin-bottom: 6px; }
|
||||
.ap-args {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 9px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.ap-actions { display: flex; gap: 8px; }
|
||||
.ap-btn {
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
}
|
||||
.ap-btn.reject { background: var(--c-text-2); }
|
||||
.ap-btn.reject:hover { background: var(--c-text); }
|
||||
.ap-btn.allow { background: var(--c-primary); }
|
||||
.ap-btn.allow:hover { background: var(--c-primary-strong); }
|
||||
.ap-done { font-size: 12px; font-weight: 600; margin-bottom: 8px; }
|
||||
.ap-done.ok { color: var(--c-ok); }
|
||||
.ap-done.no { color: var(--c-err); }
|
||||
|
||||
/* 流式实时文本(D10) */
|
||||
.ai-stream {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--c-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 15px;
|
||||
background: var(--c-primary);
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
animation: blink 0.9s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.delta-row { color: var(--c-caption); }
|
||||
|
||||
/* ---------- Composer ---------- */
|
||||
.composer-wrap { padding: 10px 20px 16px; }
|
||||
.composer {
|
||||
|
||||
Reference in New Issue
Block a user