重构: 审批交互重设计
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
'ai-tool-card--' + tc.status,
|
||||
{ '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)">
|
||||
@@ -31,20 +32,35 @@
|
||||
<!-- Body: 收起态隐藏(running/pending 强制展开) -->
|
||||
<div v-show="isExpanded || shouldKeepOpen(tc)" class="ai-tool-body">
|
||||
|
||||
<!-- Running 骨架屏 -->
|
||||
<div v-if="tc.status === 'running'" class="ai-tool-skeleton">
|
||||
<!-- Running 骨架屏(有流式输出时隐藏骨架,显实时行) -->
|
||||
<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:85%"></div>
|
||||
<div class="ai-tool-skeleton-line" style="width:40%"></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="argsEntries().length" class="ai-tool-approval-args">
|
||||
<div v-for="arg in argsEntries()" :key="arg.key" class="ai-tool-arg">
|
||||
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系) -->
|
||||
<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-val">{{ displayArgValue(arg) }}</span>
|
||||
</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 v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
|
||||
<!-- BUG-260624-02: 审批到期倒计时(与前端自动拒绝时刻精确对齐,≤60s 红色脉冲);
|
||||
@@ -156,7 +172,7 @@ import {
|
||||
} from '@/composables/ai/useToolCard'
|
||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||
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 { callDurationText } from '@/composables/ai/useTurnTrace'
|
||||
import { formatDate, formatDuration } from '@/utils/time'
|
||||
@@ -294,6 +310,25 @@ const statusTitle = computed(() => {
|
||||
|
||||
/** 头部摘要 hover 回看完整路径(摘要统一缩略尾段,全路径走 title;路径类工具才有值) */
|
||||
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>
|
||||
|
||||
<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-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; } }
|
||||
|
||||
/* -- 审批参数展示 -- */
|
||||
@@ -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-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); }
|
||||
|
||||
/* -- 风险着色条(审批挂起时卡片左缘 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>
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
<div v-if="pendingCount > 0" class="ai-tool-topbar">
|
||||
<!-- 批量操作栏(存在待审批项时显示) -->
|
||||
<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 }) }}
|
||||
</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') }}
|
||||
</button>
|
||||
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果 -->
|
||||
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint">
|
||||
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +41,14 @@
|
||||
:class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 批量批准 High 风险二次确认(本组件自治 useConfirm,不与父级串台) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -135,52 +135,59 @@
|
||||
</div>
|
||||
<!-- 目标计数 -->
|
||||
<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>
|
||||
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
|
||||
</span>
|
||||
<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' }">
|
||||
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
|
||||
<span v-else class="ai-goal-check">✓</span>
|
||||
<span class="ai-goal-text">{{ g.text }}</span>
|
||||
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
|
||||
<Transition name="ai-pop">
|
||||
<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' }">
|
||||
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
|
||||
<span v-else class="ai-goal-check">✓</span>
|
||||
<span class="ai-goal-text">{{ g.text }}</span>
|
||||
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<!-- 历史消息 -->
|
||||
<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>
|
||||
{{ historyMsgs.length }}
|
||||
</span>
|
||||
<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)">
|
||||
<span class="ai-history-index">{{ h.num }}</span>
|
||||
<span class="ai-history-text">{{ h.text }}</span>
|
||||
<span class="ai-history-time">{{ h.time }}</span>
|
||||
<Transition name="ai-pop">
|
||||
<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)">
|
||||
<span class="ai-history-index">{{ h.num }}</span>
|
||||
<span class="ai-history-text">{{ h.text }}</span>
|
||||
<span class="ai-history-time">{{ h.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<!-- 上下文摘要 -->
|
||||
<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>
|
||||
{{ summaryMsgs.length }}
|
||||
</span>
|
||||
<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)">
|
||||
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
|
||||
<p class="ai-summary-preview">{{ s.text }}</p>
|
||||
<Transition name="ai-pop">
|
||||
<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)">
|
||||
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
|
||||
<p class="ai-summary-preview">{{ s.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<!-- L3 完整上下文 -->
|
||||
<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>
|
||||
</span>
|
||||
<div v-if="contextExpanded" class="ai-context-inline-list">
|
||||
<Transition name="ai-pop">
|
||||
<div v-if="contextExpanded" class="ai-context-inline-list">
|
||||
<!-- 系统提示 -->
|
||||
<div class="ai-context-section">
|
||||
<div class="ai-context-section-title">{{ $t('aiChat.contextSystemPrompt') }}</div>
|
||||
@@ -210,6 +217,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</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-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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -38,6 +38,9 @@ const HIGH_RISK_TOOLS_FALLBACK = new Set<string>([
|
||||
/** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(走兜底名单) */
|
||||
let registryHighRisk: Set<string> | null = null
|
||||
|
||||
/** 后端注册表投影的 Medium 风险名单;null = 尚未拉到元数据(无兜底,安全侧降级不显徽章) */
|
||||
let registryMediumRisk: Set<string> | null = null
|
||||
|
||||
/**
|
||||
* 用后端 ai_list_tools 元数据构建 High 风险名单(权威源,整体覆盖兜底)。
|
||||
* 空结果不覆盖:注册表为空/数据异常时保留兜底名单,安全侧不放松。
|
||||
@@ -46,6 +49,8 @@ let registryHighRisk: Set<string> | 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 类)。
|
||||
*
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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}」执行较久,请稍候…',
|
||||
|
||||
Reference in New Issue
Block a user