重构: 审批交互重设计

This commit is contained in:
lxy
2026-10-05 14:59:12 +08:00
parent 24d6803ecc
commit 2eea0118db
7 changed files with 254 additions and 30 deletions
+86 -5
View File
@@ -5,6 +5,7 @@
'ai-tool-card--' + tc.status, 'ai-tool-card--' + tc.status,
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isFailed }, { 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isFailed },
]" ]"
:data-risk="riskLevel"
> >
<!-- 卡片头部:图标 + 名称 + 状态 --> <!-- 卡片头部:图标 + 名称 + 状态 -->
<div class="ai-tool-header" @click="emit('toggle', tc.id)"> <div class="ai-tool-header" @click="emit('toggle', tc.id)">
@@ -31,20 +32,35 @@
<!-- Body: 收起态隐藏(running/pending 强制展开) --> <!-- Body: 收起态隐藏(running/pending 强制展开) -->
<div v-show="isExpanded || shouldKeepOpen(tc)" class="ai-tool-body"> <div v-show="isExpanded || shouldKeepOpen(tc)" class="ai-tool-body">
<!-- Running 骨架屏 --> <!-- Running 骨架屏(有流式输出时隐藏骨架,显实时行) -->
<div v-if="tc.status === 'running'" class="ai-tool-skeleton"> <div v-if="tc.status === 'running' && !tc.streamLines?.length" class="ai-tool-skeleton">
<div class="ai-tool-skeleton-line" style="width:60%"></div> <div class="ai-tool-skeleton-line" style="width:60%"></div>
<div class="ai-tool-skeleton-line" style="width:85%"></div> <div class="ai-tool-skeleton-line" style="width:85%"></div>
<div class="ai-tool-skeleton-line" style="width:40%"></div> <div class="ai-tool-skeleton-line" style="width:40%"></div>
</div> </div>
<!-- 执行中实时流式输出(run_command shell 行 / ask_uclaw 进度块):
尾部最多 8 行(溢出折叠提示),Completed 后清空走 result 渲染 -->
<div v-if="tc.status === 'running' && tc.streamLines?.length" class="ai-tool-stream">
<div v-for="(ln, i) in tc.streamLines.slice(-8)" :key="tc.streamLines.length - 8 + i" class="ai-tool-stream-line">{{ ln }}</div>
<div v-if="tc.streamLines.length > 8" class="ai-tool-stream-more">{{ $t('aiTool.streamMore', { n: tc.streamLines.length - 8 }) }}</div>
</div>
<!-- 审批:参数内容 + 风险提示 + 按钮 --> <!-- 审批:参数内容 + 风险提示 + 按钮 -->
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval"> <div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
<div v-if="argsEntries().length" class="ai-tool-approval-args"> <!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系) -->
<div v-for="arg in argsEntries()" :key="arg.key" class="ai-tool-arg"> <div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge" :class="'is-' + riskLevel">
{{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }}
</div>
<div v-if="visibleArgs.length" class="ai-tool-approval-args">
<div v-for="arg in visibleArgs" :key="arg.key" class="ai-tool-arg">
<span class="ai-tool-arg-key">{{ arg.label }}</span> <span class="ai-tool-arg-key">{{ arg.label }}</span>
<span class="ai-tool-arg-val">{{ displayArgValue(arg) }}</span> <span class="ai-tool-arg-val">{{ displayArgValue(arg) }}</span>
</div> </div>
<!-- args 折叠:多参数默认仅显首条,「更多 N 项」展开(治超长 content/task 刷屏) -->
<button v-if="argsCount > 1" class="ai-tool-args-toggle" @click="argsExpanded = !argsExpanded">
{{ argsExpanded ? $t('aiTool.argsCollapse') : $t('aiTool.argsMore', { n: argsCount - 1 }) }}
</button>
</div> </div>
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div> <div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
<!-- BUG-260624-02: 审批到期倒计时(与前端自动拒绝时刻精确对齐,≤60s 红色脉冲); <!-- BUG-260624-02: 审批到期倒计时(与前端自动拒绝时刻精确对齐,≤60s 红色脉冲);
@@ -156,7 +172,7 @@ import {
} from '@/composables/ai/useToolCard' } from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender' import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader' import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval } from '@/composables/ai/useToolApproval' import { useToolApproval, toolRiskLevel } from '@/composables/ai/useToolApproval'
import { useTimerOwnership } from '@/composables/useTimerOwnership' import { useTimerOwnership } from '@/composables/useTimerOwnership'
import { callDurationText } from '@/composables/ai/useTurnTrace' import { callDurationText } from '@/composables/ai/useTurnTrace'
import { formatDate, formatDuration } from '@/utils/time' import { formatDate, formatDuration } from '@/utils/time'
@@ -294,6 +310,25 @@ const statusTitle = computed(() => {
/** 头部摘要 hover 回看完整路径(摘要统一缩略尾段,全路径走 title;路径类工具才有值) */ /** 头部摘要 hover 回看完整路径(摘要统一缩略尾段,全路径走 title;路径类工具才有值) */
const summaryTitle = computed(() => argString(props.tc.args, 'path')) const summaryTitle = computed(() => argString(props.tc.args, 'path'))
/**
* 风险等级(high/medium/low):审批挂起时卡片左侧着色条 + 风险徽章数据源。
* 复用 useToolApproval 注册表投影(权威源=后端 risk_level,启动期 loadToolRisks 同步),
* 仅 pending_approval 态展示——执行中/完成后风险条无操作意义,不抢视觉焦点。
*/
const riskLevel = computed(() =>
props.tc.status === 'pending_approval' ? toolRiskLevel(props.tc.name) : 'low',
)
/** 审批参数折叠态:默认收起仅显首条(args 通常含超长 content/task),点「更多」展开全部 */
const argsExpanded = ref(false)
/** args 条数(供「更多 N 项」文案;无参数时 0) */
const argsCount = computed(() => argsEntries().length)
/** 折叠态展示条数:展开显全部;收起显首条(最重要的参数,如 path/command) */
const visibleArgs = computed(() => {
const all = argsEntries()
return argsExpanded.value ? all : all.slice(0, 1)
})
</script> </script>
<style scoped> <style scoped>
@@ -389,6 +424,23 @@ const summaryTitle = computed(() => argString(props.tc.args, 'path'))
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%); background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite; background-size: 200% 100%; animation: shimmer 1.5s infinite;
} }
/* -- 实时流式输出(running 态:run_command 行 / ask_uclaw 进度块) -- */
.ai-tool-stream {
padding: 4px 10px 10px 42px;
display: flex; flex-direction: column; gap: 2px;
max-height: 160px; overflow-y: auto;
}
.ai-tool-stream-line {
font-family: var(--df-font-mono, monospace);
font-size: 11px; line-height: 1.5;
color: var(--df-text-secondary);
white-space: pre-wrap; word-break: break-all;
}
.ai-tool-stream-more {
font-size: 10px; color: var(--df-text-tertiary);
padding-top: 2px; font-style: italic;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* -- 审批参数展示 -- */ /* -- 审批参数展示 -- */
@@ -474,4 +526,33 @@ const summaryTitle = computed(() => argString(props.tc.args, 'path'))
.ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); } .ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; } .ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-tool-toast-enter-from, .ai-tool-toast-leave-to { opacity: 0; transform: translate(-50%, -6px); } .ai-tool-toast-enter-from, .ai-tool-toast-leave-to { opacity: 0; transform: translate(-50%, -6px); }
/* -- 风险着色条(审批挂起时卡片左缘 2px 竖条,High 橙红/Medium 黄;data-risk 属性驱动) -- */
.ai-tool-card[data-risk='high'] { border-left: 2px solid var(--df-danger); }
.ai-tool-card[data-risk='medium'] { border-left: 2px solid var(--df-warning); }
/* -- 风险徽章(审批区首行,High 橙红底/Medium 黄底,10px 大写字距) -- */
.ai-tool-risk-badge {
display: inline-flex; align-items: center; align-self: flex-start;
padding: 2px 8px; margin: 6px 10px 0;
font-size: 10px; font-weight: 600; letter-spacing: 0.5px;
border-radius: var(--df-radius-sm);
}
.ai-tool-risk-badge.is-high { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent); }
.ai-tool-risk-badge.is-medium { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent); }
/* 审批区改纵向 flex(徽章需独立成行,原块级布局不变) */
.ai-tool-approval { display: flex; flex-direction: column; }
.ai-tool-approval-args { margin-bottom: 0; }
/* -- args 折叠切换按钮(「更多 N 项」/「收起」;弱化灰字,hover 变主文字色) -- */
.ai-tool-args-toggle {
display: block; width: 100%; text-align: center;
padding: 3px 0; margin-top: 2px;
font-size: 10px; color: var(--df-text-dim);
background: none; border: none; border-top: 0.5px dashed var(--df-border);
cursor: pointer; transition: color 0.15s var(--df-ease);
font-family: var(--df-font-sans);
}
.ai-tool-args-toggle:hover { color: var(--df-text); }
</style> </style>
+61 -2
View File
@@ -4,12 +4,19 @@
<div v-if="pendingCount > 0" class="ai-tool-topbar"> <div v-if="pendingCount > 0" class="ai-tool-topbar">
<!-- 批量操作栏(存在待审批项时显示) --> <!-- 批量操作栏(存在待审批项时显示) -->
<div class="ai-batch-approve"> <div class="ai-batch-approve">
<button class="ai-btn ai-btn--sm" @click="$emit('batch-approve', 'approve')"> <!-- 批量批准含 High 风险工具时前置二次确认(与单卡 onApprove 同门槛;
纯低风险批量保持一键直批) -->
<button class="ai-btn ai-btn--sm" :disabled="batchProcessing" @click="onBatchApprove">
<span v-if="batchProcessing" class="ai-btn-spinner" />
{{ $t('aiTool.approveAll', { n: pendingCount }) }} {{ $t('aiTool.approveAll', { n: pendingCount }) }}
</button> </button>
<button class="ai-btn ai-btn--sm ai-btn--danger" @click="$emit('batch-approve', 'reject')"> <button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
{{ $t('aiTool.rejectAll') }} {{ $t('aiTool.rejectAll') }}
</button> </button>
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果 -->
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint">
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
</span>
</div> </div>
</div> </div>
@@ -34,6 +41,14 @@
:class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }" :class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }"
/> />
</template> </template>
<!-- 批量批准 High 风险二次确认(本组件自治 useConfirm,不与父级串台) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="t('common.confirm')"
@result="answerConfirm"
/>
</div> </div>
</template> </template>
@@ -41,8 +56,11 @@
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue' import ToolCard from './ToolCard.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiToolCallInfo } from '@/api/types' 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) // useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
const { t } = useI18n() const { t } = useI18n()
@@ -123,6 +141,37 @@ const pendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval').length 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)且用户未手动操作过的组自动收起。 * 默认收起补齐:达到多卡阈值(>=2)且用户未手动操作过的组自动收起。
* 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑, * 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑,
@@ -398,6 +447,16 @@ function groupIcon(name: string): string {
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: rgba(255,255,255,0.02); 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 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
.ai-btn { .ai-btn {
display: inline-flex; display: inline-flex;
+36 -4
View File
@@ -135,10 +135,11 @@
</div> </div>
<!-- 目标计数 --> <!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline"> <div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge ai-tool-badge" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')"> <span class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }} {{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
</span> </span>
<Transition name="ai-pop">
<div v-if="goalsExpanded" class="ai-goals-inline-list"> <div v-if="goalsExpanded" class="ai-goals-inline-list">
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }"> <div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }">
<span v-if="g.status === 'active'" class="ai-goal-dot"></span> <span v-if="g.status === 'active'" class="ai-goal-dot"></span>
@@ -147,13 +148,15 @@
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button> <button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
</div> </div>
</div> </div>
</Transition>
</div> </div>
<!-- 历史消息 --> <!-- 历史消息 -->
<div v-if="historyMsgs.length" class="ai-history-inline"> <div v-if="historyMsgs.length" class="ai-history-inline">
<span class="ai-history-inline-badge ai-tool-badge" @click="togglePanel('history')" :title="$t('aiChat.viewHistory')"> <span class="ai-history-inline-badge ai-tool-badge" :class="{ 'is-active': historyExpanded }" @click="togglePanel('history')" :title="$t('aiChat.viewHistory')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ historyMsgs.length }} {{ historyMsgs.length }}
</span> </span>
<Transition name="ai-pop">
<div v-if="historyExpanded" class="ai-history-inline-list"> <div v-if="historyExpanded" class="ai-history-inline-list">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)"> <div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
<span class="ai-history-index">{{ h.num }}</span> <span class="ai-history-index">{{ h.num }}</span>
@@ -161,25 +164,29 @@
<span class="ai-history-time">{{ h.time }}</span> <span class="ai-history-time">{{ h.time }}</span>
</div> </div>
</div> </div>
</Transition>
</div> </div>
<!-- 上下文摘要 --> <!-- 上下文摘要 -->
<div v-if="summaryMsgs.length" class="ai-summary-inline"> <div v-if="summaryMsgs.length" class="ai-summary-inline">
<span class="ai-summary-inline-badge ai-tool-badge" @click="togglePanel('summary')" :title="$t('aiChat.viewSummary')"> <span class="ai-summary-inline-badge ai-tool-badge" :class="{ 'is-active': summaryExpanded }" @click="togglePanel('summary')" :title="$t('aiChat.viewSummary')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>
{{ summaryMsgs.length }} {{ summaryMsgs.length }}
</span> </span>
<Transition name="ai-pop">
<div v-if="summaryExpanded" class="ai-summary-inline-list"> <div v-if="summaryExpanded" class="ai-summary-inline-list">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)"> <div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span> <span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p> <p class="ai-summary-preview">{{ s.text }}</p>
</div> </div>
</div> </div>
</Transition>
</div> </div>
<!-- L3 完整上下文 --> <!-- L3 完整上下文 -->
<div v-if="contextInfo.goals.length || contextInfo.summaryCount > 0" class="ai-context-inline"> <div v-if="contextInfo.goals.length || contextInfo.summaryCount > 0" class="ai-context-inline">
<span class="ai-context-inline-badge ai-tool-badge" @click="togglePanel('context')" :title="$t('aiChat.viewContext')"> <span class="ai-context-inline-badge ai-tool-badge" :class="{ 'is-active': contextExpanded }" @click="togglePanel('context')" :title="$t('aiChat.viewContext')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg>
</span> </span>
<Transition name="ai-pop">
<div v-if="contextExpanded" class="ai-context-inline-list"> <div v-if="contextExpanded" class="ai-context-inline-list">
<!-- 系统提示 --> <!-- 系统提示 -->
<div class="ai-context-section"> <div class="ai-context-section">
@@ -210,6 +217,7 @@
</div> </div>
</div> </div>
</div> </div>
</Transition>
</div> </div>
</div> </div>
</div> </div>
@@ -1130,4 +1138,28 @@ onBeforeUnmount(() => {
.ai-provider-switch-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } .ai-provider-switch-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
.ai-provider-switch-enter-to, .ai-provider-switch-enter-to,
.ai-provider-switch-leave-from { opacity: 1; max-height: 32px; } .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;
}
</style> </style>
+16
View File
@@ -42,6 +42,8 @@ export function handleToolEvent(event: AiChatEvent): boolean {
tc.status = 'completed' tc.status = 'completed'
tc.result = event.result tc.result = event.result
tc.completedAt = Date.now() tc.completedAt = Date.now()
// 清流式缓冲:结果由 result 渲染,流式行是执行中间态,留着会双显
tc.streamLines = undefined
} }
// 移除待审批(含 per-conv 集合,后台会话集合里的同 id 挂起一并清) // 移除待审批(含 per-conv 集合,后台会话集合里的同 id 挂起一并清)
removePendingApprovalById(event.id) removePendingApprovalById(event.id)
@@ -57,6 +59,20 @@ export function handleToolEvent(event: AiChatEvent): boolean {
return true 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': { case 'AiApprovalRequired': {
clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计整流超时 clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计整流超时
const info: AiToolCallInfo = { const info: AiToolCallInfo = {
+16
View File
@@ -38,6 +38,9 @@ const HIGH_RISK_TOOLS_FALLBACK = new Set<string>([
/** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(走兜底名单) */ /** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(走兜底名单) */
let registryHighRisk: Set<string> | null = null let registryHighRisk: Set<string> | null = null
/** 后端注册表投影的 Medium 风险名单;null = 尚未拉到元数据(无兜底,安全侧降级不显徽章) */
let registryMediumRisk: Set<string> | null = null
/** /**
* 用后端 ai_list_tools 元数据构建 High 风险名单(权威源,整体覆盖兜底)。 * 用后端 ai_list_tools 元数据构建 High 风险名单(权威源,整体覆盖兜底)。
* 空结果不覆盖:注册表为空/数据异常时保留兜底名单,安全侧不放松。 * 空结果不覆盖:注册表为空/数据异常时保留兜底名单,安全侧不放松。
@@ -46,6 +49,8 @@ let registryHighRisk: Set<string> | null = null
export function syncHighRiskTools(tools: { name: string; risk: string }[]): void { export function syncHighRiskTools(tools: { name: string; risk: string }[]): void {
const high = tools.filter((t) => t.risk === 'high').map((t) => t.name) const high = tools.filter((t) => t.risk === 'high').map((t) => t.name)
if (high.length > 0) registryHighRisk = new Set(high) 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) 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 类)。 * 审批 IPC 分派(共享):按 tc.kind 决定调 aiApi.authorizeDir(path 类)或 aiApi.approve(risk 类)。
* *
+10
View File
@@ -1,10 +1,20 @@
export default { export default {
aiTool: { aiTool: {
running: 'Running…', running: 'Running…',
streamMore: '… {n} earlier hidden',
approve: 'Approve', approve: 'Approve',
reject: 'Reject', reject: 'Reject',
approveAll: 'Approve All ({n})', approveAll: 'Approve All ({n})',
rejectAll: 'Reject All', 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', rejectedHint: 'User rejected this action',
// tool execution slow hint (frontend fallback, no backend cancel IPC) // tool execution slow hint (frontend fallback, no backend cancel IPC)
executionSlow: 'Tool "{name}" is taking longer than expected, please wait…', executionSlow: 'Tool "{name}" is taking longer than expected, please wait…',
+10
View File
@@ -1,10 +1,20 @@
export default { export default {
aiTool: { aiTool: {
running: '执行中…', running: '执行中…',
streamMore: '… 已省略前 {n} 条',
approve: '批准执行', approve: '批准执行',
reject: '拒绝', reject: '拒绝',
approveAll: '全部批准 {n} 项', approveAll: '全部批准 {n} 项',
rejectAll: '全部拒绝', rejectAll: '全部拒绝',
// 风险等级徽章(审批卡着色条+徽标;medium 仅注册表投影,无兜底)
riskHigh: '高风险',
riskMedium: '中风险',
// args 折叠(多参数默认显首条,「更多 N 项」展开)
argsMore: '更多 {n} 项',
argsCollapse: '收起参数',
// 批量批准 High 风险拦截(计数徽标 + 二次确认文案)
batchHighRiskHint: '含 {n} 项高风险',
batchHighRiskConfirm: '批量批准包含 {n} 项高风险操作(如删除/命令执行/网络请求),确认全部放行?',
rejectedHint: '用户拒绝了此操作', rejectedHint: '用户拒绝了此操作',
// 工具执行超时提示(前端降级,无后端取消 IPC) // 工具执行超时提示(前端降级,无后端取消 IPC)
executionSlow: '工具「{name}」执行较久,请稍候…', executionSlow: '工具「{name}」执行较久,请稍候…',