diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 974abc3..8eb6417 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -5,6 +5,7 @@ 'ai-tool-card--' + tc.status, { 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isFailed }, ]" + :data-risk="riskLevel" >
@@ -31,20 +32,35 @@
- -
+ +
+ +
+
{{ ln }}
+
{{ $t('aiTool.streamMore', { n: tc.streamLines.length - 8 }) }}
+
+
-
-
+ +
+ {{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }} +
+
+
{{ arg.label }} {{ displayArgValue(arg) }}
+ +
⚠ {{ tc.reason }}
- - + + + {{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }} +
@@ -34,6 +41,14 @@ :class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }" /> + + +
@@ -41,8 +56,11 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import ToolCard from './ToolCard.vue' +import ConfirmDialog from './ConfirmDialog.vue' import { listen, type UnlistenFn } from '@tauri-apps/api/event' import type { AiToolCallInfo } from '@/api/types' +import { useConfirm } from '@/composables/useConfirm' +import { toolRiskLevel } from '@/composables/ai/useToolApproval' // useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key) const { t } = useI18n() @@ -123,6 +141,37 @@ const pendingCount = computed(() => props.toolCalls.filter(tc => tc.status === 'pending_approval').length ) +// -- 批量批准 High 风险拦截(治安全缺口:原批量批准绕过单卡二次确认直接放行) -- +const { confirmState, confirmDialog, answerConfirm } = useConfirm() +/** 待审批中的 High 风险项数(徽标提示 + 确认门槛判定) */ +const highRiskPendingCount = computed(() => + props.toolCalls.filter(tc => tc.status === 'pending_approval' && toolRiskLevel(tc.name) === 'high').length +) +/** 批量处理中(透传 isBatchProcessing 不便——本组件直接用 pendingCount 收敛到 0 判断,简单可靠) */ +const batchProcessing = ref(false) + +/** + * 批量批准入口:含 High 风险项 → 先二次确认(文案对齐单卡 highRiskConfirmText 语义, + * 列出将放行的高危工具数);确认后才向父级派发 batch-approve('approve')。 + * 拒绝批量无破坏性,不拦。 + */ +async function onBatchApprove() { + if (highRiskPendingCount.value > 0) { + const ok = await confirmDialog( + t('aiTool.batchHighRiskConfirm', { n: highRiskPendingCount.value }), + ) + if (!ok) return + } + batchProcessing.value = true + emit('batch-approve', 'approve') + // emit 后父级异步批量处理;本组件无完成回执,以 pendingCount 归零兜底复位 + const stop = watch(pendingCount, (n, old) => { + if (n === 0 || n < old) { batchProcessing.value = false; stop() } + }, { flush: 'post' }) + // 兜底:pendingCount 因新增挂起不清零时 3s 后自动复位 + setTimeout(() => { batchProcessing.value = false }, 3000) +} + /** * 默认收起补齐:达到多卡阈值(>=2)且用户未手动操作过的组自动收起。 * 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑, @@ -398,6 +447,16 @@ function groupIcon(name: string): string { border-radius: var(--df-radius-sm); background: rgba(255,255,255,0.02); } +/* 批量批准将二次确认的 High 风险项数徽标(橙红警示,不抢按钮焦点) */ +.ai-batch-high-risk-hint { + font-size: 10px; + color: var(--df-danger); + background: var(--df-danger-bg); + border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent); + border-radius: var(--df-radius-sm); + padding: 2px 8px; + white-space: nowrap; +} /* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */ .ai-btn { display: inline-flex; diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index cf8c982..a61e0e9 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -135,52 +135,59 @@
- + {{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }} -
-
- - ✓ - {{ g.text }} - + +
+
+ + ✓ + {{ g.text }} + +
-
+
- + {{ historyMsgs.length }} -
-
- {{ h.num }} - {{ h.text }} - {{ h.time }} + +
+
+ {{ h.num }} + {{ h.text }} + {{ h.time }} +
-
+
- + {{ summaryMsgs.length }} -
-
- {{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }} -

{{ s.text }}

+ +
+
+ {{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }} +

{{ s.text }}

+
-
+
- + -
+ +
{{ $t('aiChat.contextSystemPrompt') }}
@@ -210,6 +217,7 @@
+
@@ -1130,4 +1138,28 @@ onBeforeUnmount(() => { .ai-provider-switch-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } .ai-provider-switch-enter-to, .ai-provider-switch-leave-from { opacity: 1; max-height: 32px; } + +/* ═══ 底部四面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */ +.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); } +.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); } +.ai-pop-enter-from, +.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); } + +/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */ +.ai-tool-badge.is-active { + color: var(--df-accent); + border-color: color-mix(in srgb, var(--df-accent) 45%, transparent); + background: var(--df-accent-bg); +} + +/* goals 面板补 max-height 滚动(原无上限,目标多时无限拉高遮对话) */ +.ai-goals-inline-list { + max-height: 300px; + overflow-y: auto; +} +/* context 面板补 max-height 滚动(原无上限,增强上下文多时同理) */ +.ai-context-inline-list { + max-height: 360px; + overflow-y: auto; +} diff --git a/src/composables/ai/useAiToolEvents.ts b/src/composables/ai/useAiToolEvents.ts index abdaf29..94747f6 100644 --- a/src/composables/ai/useAiToolEvents.ts +++ b/src/composables/ai/useAiToolEvents.ts @@ -42,6 +42,8 @@ export function handleToolEvent(event: AiChatEvent): boolean { tc.status = 'completed' tc.result = event.result tc.completedAt = Date.now() + // 清流式缓冲:结果由 result 渲染,流式行是执行中间态,留着会双显 + tc.streamLines = undefined } // 移除待审批(含 per-conv 集合,后台会话集合里的同 id 挂起一并清) removePendingApprovalById(event.id) @@ -57,6 +59,20 @@ export function handleToolEvent(event: AiChatEvent): boolean { return true } + case 'AiCommandOutput': { + // 工具执行实时流式(run_command shell 行级 / ask_uclaw 进度块级): + // 按 id 路由到对应工具卡,streamLines 追加(上限 200 行防长任务刷爆响应式数组, + // 超限丢头保尾——最新进度更有价值)。Completed 时清空(结果由 result 渲染,防双显)。 + // 找不到工具卡(极早到/已收尾)静默忽略。 + const tc = findToolCall(event.id) + if (tc) { + if (!tc.streamLines) tc.streamLines = [] + tc.streamLines.push(event.line) + if (tc.streamLines.length > 200) tc.streamLines.splice(0, tc.streamLines.length - 200) + } + return true + } + case 'AiApprovalRequired': { clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计整流超时 const info: AiToolCallInfo = { diff --git a/src/composables/ai/useToolApproval.ts b/src/composables/ai/useToolApproval.ts index 7e44c89..8b6d4b5 100644 --- a/src/composables/ai/useToolApproval.ts +++ b/src/composables/ai/useToolApproval.ts @@ -38,6 +38,9 @@ const HIGH_RISK_TOOLS_FALLBACK = new Set([ /** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(走兜底名单) */ let registryHighRisk: Set | null = null +/** 后端注册表投影的 Medium 风险名单;null = 尚未拉到元数据(无兜底,安全侧降级不显徽章) */ +let registryMediumRisk: Set | null = null + /** * 用后端 ai_list_tools 元数据构建 High 风险名单(权威源,整体覆盖兜底)。 * 空结果不覆盖:注册表为空/数据异常时保留兜底名单,安全侧不放松。 @@ -46,6 +49,8 @@ let registryHighRisk: Set | null = null export function syncHighRiskTools(tools: { name: string; risk: string }[]): void { const high = tools.filter((t) => t.risk === 'high').map((t) => t.name) if (high.length > 0) registryHighRisk = new Set(high) + const med = tools.filter((t) => t.risk === 'medium').map((t) => t.name) + if (med.length > 0) registryMediumRisk = new Set(med) } /** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单。 @@ -54,6 +59,17 @@ export function isHighRiskTool(name: string): boolean { return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name) } +/** + * 工具风险等级判定(high/medium/low):审批卡着色条+徽章数据源。 + * high 优先注册表投影、未拉到时退兜底名单(与 isHighRiskTool 同口径); + * medium 仅注册表投影,无兜底——未拉到元数据时不误标中危(安全侧降级:少显徽章优于乱标)。 + */ +export function toolRiskLevel(name: string): 'high' | 'medium' | 'low' { + if ((registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)) return 'high' + if (registryMediumRisk?.has(name)) return 'medium' + return 'low' +} + /** * 审批 IPC 分派(共享):按 tc.kind 决定调 aiApi.authorizeDir(path 类)或 aiApi.approve(risk 类)。 * diff --git a/src/i18n/en/aiTool.ts b/src/i18n/en/aiTool.ts index 796ceaa..2b59686 100644 --- a/src/i18n/en/aiTool.ts +++ b/src/i18n/en/aiTool.ts @@ -1,10 +1,20 @@ export default { aiTool: { running: 'Running…', + streamMore: '… {n} earlier hidden', approve: 'Approve', reject: 'Reject', approveAll: 'Approve All ({n})', rejectAll: 'Reject All', + // risk level badge (approval card color bar + badge; medium registry-only, no fallback) + riskHigh: 'HIGH RISK', + riskMedium: 'MEDIUM RISK', + // args folding (multi-arg default shows first, "N more" expands) + argsMore: '{n} more', + argsCollapse: 'Collapse args', + // batch approve high-risk interception (count badge + confirm text) + batchHighRiskHint: '{n} high-risk', + batchHighRiskConfirm: 'Batch approval includes {n} high-risk operations (e.g. delete/command execution/network requests). Approve all?', rejectedHint: 'User rejected this action', // tool execution slow hint (frontend fallback, no backend cancel IPC) executionSlow: 'Tool "{name}" is taking longer than expected, please wait…', diff --git a/src/i18n/zh-CN/aiTool.ts b/src/i18n/zh-CN/aiTool.ts index a170f1e..9221b49 100644 --- a/src/i18n/zh-CN/aiTool.ts +++ b/src/i18n/zh-CN/aiTool.ts @@ -1,10 +1,20 @@ export default { aiTool: { running: '执行中…', + streamMore: '… 已省略前 {n} 条', approve: '批准执行', reject: '拒绝', approveAll: '全部批准 {n} 项', rejectAll: '全部拒绝', + // 风险等级徽章(审批卡着色条+徽标;medium 仅注册表投影,无兜底) + riskHigh: '高风险', + riskMedium: '中风险', + // args 折叠(多参数默认显首条,「更多 N 项」展开) + argsMore: '更多 {n} 项', + argsCollapse: '收起参数', + // 批量批准 High 风险拦截(计数徽标 + 二次确认文案) + batchHighRiskHint: '含 {n} 项高风险', + batchHighRiskConfirm: '批量批准包含 {n} 项高风险操作(如删除/命令执行/网络请求),确认全部放行?', rejectedHint: '用户拒绝了此操作', // 工具执行超时提示(前端降级,无后端取消 IPC) executionSlow: '工具「{name}」执行较久,请稍候…',