优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)

This commit is contained in:
2026-06-18 22:57:19 +08:00
parent 0ca5d9805f
commit a2871a66e0
87 changed files with 5720 additions and 3012 deletions

View File

@@ -80,9 +80,22 @@
<template v-else>
<!-- 活跃对话: 今天/昨天/更早 分组 -->
<template v-for="group in groupedActive" :key="'g-' + group.key">
<div class="ai-conv-group-title">{{ group.label }}</div>
<div
class="ai-conv-group-title ai-conv-group-title--fold"
@click="store.toggleGroupFold(group.key)"
>
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.foldedGroups[group.key] }"></span>
{{ group.label }}
<span class="ai-conv-group-count">({{ group.items.length }})</span>
</div>
<div
v-if="group.key === 'today' && !group.items.length"
v-show="!store.state.foldedGroups[group.key]"
class="ai-conv-group-empty"
>{{ $t('aiChat.groupEmpty') }}</div>
<div
v-for="conv in group.items"
v-show="!store.state.foldedGroups[group.key]"
:key="conv.id"
class="ai-conv-item"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
@@ -91,7 +104,7 @@
<div class="ai-conv-item-main">
<input
v-if="editingConvId === conv.id"
ref="editEl"
:ref="setEditEl"
v-model="editText"
class="ai-conv-item-edit"
@keyup.enter="commitRename(conv.id)"
@@ -364,14 +377,17 @@
<!-- 消息列表(F-15 阶段2: renderItems 扁平渲染 折叠段 emit 'sep' ,
正常消息 + 展开的折叠段内消息 emit 'msg' 连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文)
UX-2025-19: 每项外层包 sentinel div(虚拟滚动观测点 + 高度占位),
不可见窗口外的 msg 内容卸载sentinel 留存占位高度(scrollHeight 不塌) -->
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap .ai-messages 承担) -->
<template v-for="item in renderItems" :key="item.key">
<!-- B-260618-19: 纯空 assistant( content/error/toolCalls 且非末条流式) slot 不渲染,
消除空 div + flex gap 堆积的大片空白;sep 始终渲染; toolCalls 的空 assistant
仍渲染(工具卡可见修复 agentic 工具轮 LLM 无文本时工具卡消失) -->
<div
v-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
class="ai-msg-slot"
:ref="(el) => registerMsgSentinel(item.key, el as Element)"
>
<!-- 折叠分隔条(归档段 / 压缩段) 轻量,始终渲染不过虚拟化 -->
<!-- 折叠分隔条(归档段) 轻量,始终渲染 -->
<div
v-if="item.kind === 'sep'"
class="ai-msg-segment"
@@ -395,26 +411,29 @@
</span>
</div>
<!-- 消息(正常段 + 展开的折叠段内消息) 不可见时卸载(sentinel 占位高度),
流式末条经 pinnedKeys 保活(shouldRenderMsg true) -->
<!-- UX-260617-22:跳过已完成且无内容且非错误的 AI 消息
原模板 ai-md 气泡 v-if content 非空才渲染,空内容只剩 avatar+空白,渲染无意义;
仅当是末条流式(可能 text 刚开始)时不过滤,保留光标气泡占位 -->
<!-- 消息(正常段 + 展开的折叠段内消息) 虚拟滚动已移除(UX-260617-01:IO/RO 时序致重叠),消息恒渲染 -->
<!-- B-260618-19: 纯空 assistant 过滤已上提到外层 .ai-msg-slot v-if(shouldRenderMsg),
此处 v-else 恒渲染 .ai-msg(sep 已在上方 v-if 分支处理,此处必为 msg) -->
<div
v-else-if="shouldRenderMsg(item.key) && !(item.msg.role === 'assistant' && !item.msg.content && !item.msg.isError && !(isLastAi(item.msg) && store.state.streaming))"
v-else
class="ai-msg"
:class="'ai-msg--' + item.msg.role"
:data-msg-id="item.msg.id"
>
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠, messageSegments) -->
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderContent(item.msg)"></div>
</div>
<!-- 用户消息 -->
<div v-if="item.msg.role === 'user'" class="ai-msg-user">
<div v-else-if="item.msg.role === 'user'" class="ai-msg-user">
<div class="ai-msg-avatar ai-msg-avatar--user">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
{{ item.msg.content }}
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载,虚拟滚动裁剪的是
整条消息,气泡内 <img> sentinel 卸载无冲突) -->
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载;
B-260618-01 虚拟滚动已移除,气泡内 <img> 消息恒渲染无裁剪) -->
<template v-if="msgImageParts(item.msg).length">
<div class="ai-msg-images">
<img
@@ -483,23 +502,14 @@
<span class="ai-cursor"></span>
</div>
<!-- UX-02:AI 消息操作栏(hover 显示:复制 / 重新生成)
仅非错误有内容且非流式生成中的 AI 消息显示
重新生成仅最后一 AI 消息显示(语义:重生成末尾回复) -->
<!-- UX-02:AI 消息操作栏(hover 显示:重新生成)
复制走气泡内右下角图标(ai-copy-btn--ai),本栏仅留重新生成,故只在
AI 消息(重生成语义)非错误有内容非流式中显示 -->
<div
v-if="item.msg.content && !item.msg.isError && !store.state.streaming"
v-if="isLastAi(item.msg) && item.msg.content && !item.msg.isError && !store.state.streaming"
class="ai-msg-actions"
>
<button
class="ai-msg-act"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<span>{{ $t('aiChat.copyMsg') }}</span>
</button>
<button
v-if="isLastAi(item.msg)"
class="ai-msg-act"
:disabled="store.state.streaming"
:title="$t('aiChat.regenerate')"
@@ -567,7 +577,7 @@
</div>
</div>
</div>
</div><!-- /.ai-msg-slot(sentinel,UX-2025-19 虚拟滚动观测点) -->
</div><!-- /.ai-msg-slot(B-260618-01:虚拟滚动已移除,恒渲染容器) -->
</template>
</div>
@@ -758,7 +768,12 @@
</div>
</div>
<!-- 确认弹层(删对话等不可逆操作) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="confirmState.dangerLabel"
@result="answerConfirm"
/>
<!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) -->
<Transition name="ai-toast">
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
@@ -773,7 +788,6 @@ import { useRouter } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
import { useAiStore } from '../stores/ai'
import { pendingMaxRounds } from '../composables/ai/useAiEvents'
import { useAiVirtualScroll } from '../composables/ai/useAiVirtualScroll'
import { aiApi } from '../api'
import ConfirmDialog from './ConfirmDialog.vue'
import { useConfirm } from '../composables/useConfirm'
@@ -911,7 +925,13 @@ function scheduleStreamParse(text: string) {
// UX-2025-01: 末块 innerHTML 每帧替换,保存选区
saveSelection()
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
nextTick(() => restoreSelection())
nextTick(() => {
restoreSelection()
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
// 滚到渲染前高度)致"差一截"。
if (isFollowingBottom) scrollToBottom()
})
})
}
@@ -1115,13 +1135,6 @@ function imgAlt(img: ImageContentPart): string {
// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。
const editingMsgId = ref<string | null>(null)
const messagesContainer = ref<HTMLDivElement>()
// UX-2025-19: 虚拟滚动(可见窗口懒渲染 + 流式末条保活)。渲染层裁剪,不触碰既有滚动/收起/流式逻辑。
const {
registerSentinel: registerMsgSentinel,
setupOnMount: setupVirtualScroll,
shouldRender: shouldRenderMsg,
setPinned: setPinnedMsgs,
} = useAiVirtualScroll({ root: messagesContainer, rootMarginPx: 600 })
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
@@ -1256,11 +1269,16 @@ function detectMentionTrigger() {
}
// UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效)
// F-01 阶段6: 同步清 modelOverride(后端 switchConversation/new 已清 session.model_override,
// 前端 ref 不清则会与后端不一致——override 是单对话级 UI 选择,不跨对话持久化)
// 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
// 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit()
store.modelOverride.value = null
store.modelOverride.value = enabledModels.value[0]?.model_id || null
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists
wasNearBottom = true
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
isFollowingBottom = true
})
function selectSkill(s: SkillInfo) {
@@ -1387,7 +1405,12 @@ function selectMention(item: MentionItem) {
// 对话项 inline 重命名
const editingConvId = ref<string | null>(null)
const editText = ref('')
const editEl = ref<HTMLInputElement>()
// 重命名 input 元素引用。该 input 在 v-for 内且由 v-if(editingConvId===conv.id) 单实例化,
// 字符串 ref 在 v-for 中会被收集成数组(.focus/.select 失效),改用函数 ref 直接持有单个元素。
let editEl: HTMLInputElement | null = null
function setEditEl(el: Element | { $el?: Element } | null): void {
editEl = (el as HTMLInputElement) || null
}
// UX-06 §3.1: 对话搜索框引用(Ctrl+K 聚焦用)
const searchInputRef = ref<HTMLInputElement>()
@@ -1396,8 +1419,8 @@ function startRename(conv: { id: string; title: string | null }) {
editingConvId.value = conv.id
editText.value = conv.title || ''
nextTick(() => {
editEl.value?.focus()
editEl.value?.select()
editEl?.focus()
editEl?.select()
})
}
@@ -1489,7 +1512,12 @@ const activeProviderRecord = computed(() =>
const enabledModels = computed(() => {
const p = activeProviderRecord.value
if (!p?.model_configs) return []
return p.model_configs.filter(m => m.enabled)
// weight 降序(权重最高优先);slice 先复制避免 sort 污染 store 原 model_configs
// 决定下拉顺序 + 默认选中([0]=权重最高),对齐用户诉求「默认选中权重最高、不随机/不摇摆」。
return p.model_configs
.filter(m => m.enabled)
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
})
// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。
const isSpecifyMode = computed(() => store.modelOverride.value !== null)
@@ -1501,12 +1529,9 @@ const modelPickerHint = computed(() => {
? t('aiChat.specifyModeHint')
: t('aiChat.autoModeHint')
})
/** 下拉选项 label:model_id + 4 维度简标(智力/价格),复用 settings i18n 标签字面 */
/** 下拉选项 label:model_id/label(cost/intel 维度已去,UX-260618-04) */
function modelOptionLabel(m: import('../api/types').ModelConfig): string {
const name = m.label || m.model_id
const intel = t('settings.tagIntel.' + m.intelligence)
const cost = t('settings.tagCost.' + m.cost_tier)
return `${name} · ${intel} · ${cost}`
return m.label || m.model_id
}
function setAutoMode() {
store.modelOverride.value = null
@@ -1563,6 +1588,31 @@ function isLastAi(msg: AiMessage): boolean {
return msg.role === 'assistant' && msgs[msgs.length - 1]?.id === msg.id
}
/**
* B-260618-19: 是否渲染某条消息(供 .ai-msg-slot v-if 守卫)。
*
* 背景:agentic 多轮下,LLM 某轮只发 tool_calls 不输出文本时(GLM anthropic_compat 常见),
* 该轮 assistant 消息 content='' + toolCalls=[...];变中间条后,旧 v-else-if(UX-260617-22)
* 漏判 toolCalls 把它整条过滤 → 工具卡片消失,且外层 .ai-msg-slot 恒渲染留空 div,
* flex gap 堆积成大片空白,末条光标气泡随每轮 AiAgentRound push 往下移。
*
* 现仅过滤「纯空」assistant(无 content / 无 error / 无 toolCalls / 非末条流式):
* - 有 toolCalls → 渲染(工具卡可见,即便本轮 LLM 无文本)
* - 末条 + 流式 → 渲染(光标气泡占位,接收后续 delta)
* - 其余(user/system/有内容 AI)→ 渲染
* - 纯空中间条 → 不渲染(连 slot 一起省,消除空白)
*/
function shouldRenderMsg(msg: AiMessage): boolean {
if (msg.role === 'assistant'
&& !msg.content
&& !msg.isError
&& !(msg.toolCalls && msg.toolCalls.length)
&& !(isLastAi(msg) && store.state.streaming)) {
return false
}
return true
}
/** UX-09:msg 是否为 messages 中最后一条 user 消息(可编辑判定)。 */
function isLastUser(msg: AiMessage): boolean {
if (msg.role !== 'user') return false
@@ -1648,8 +1698,9 @@ const groupedActive = computed(() => {
}
const groups: { key: string; label: string; items: AiConversationSummary[] }[] = []
for (const key of ['today', 'yesterday', 'earlier']) {
const items = map.get(key)
if (items && items.length) groups.push({ key, label: BUCKET_LABELS.value[key], items })
const items = map.get(key) || []
// 「今天」恒显(空则占位「暂无」);昨天/更早仅非空时出现
if (key === 'today' || items.length) groups.push({ key, label: BUCKET_LABELS.value[key], items })
}
return groups
})
@@ -1989,6 +2040,10 @@ const showBackToBottom = ref(false)
// 收起不再活跃的旧卡(视为已读)。用 wasNearBottom 边沿检测避免每个 scroll tick 都触发:
// 仅在"远离底部 → 滚近底部"的转换瞬间收起一次,已在底部继续滚动不重复触发。
let wasNearBottom = true
// B-260618-24: 跟随意图锁存。原 onContentChange 仅靠 isNearBottom() 瞬时测量,流式 rAF 高频
// 渲染下单帧增量 >80px 即判不在底 → 跟随意图一帧丢失(点回到底部后又失跟)。锁存解耦
// "意图"与"瞬时测量":点回到底部/滚到底/发新消息→true;用户上滑→false。
let isFollowingBottom = true
function refreshBackToBottom() {
const el = messagesContainer.value
if (!el) { showBackToBottom.value = false; return }
@@ -2003,10 +2058,13 @@ function onMessagesScroll() {
collapseAllToolLists(buildActiveToolIds(store.state.messages))
}
wasNearBottom = near
// B-260618-24: 用户主动滚离底部 = 放弃跟随;滚回底部 = 恢复跟随
isFollowingBottom = near
}
function scrollToBottom(smooth = false) {
if (messagesContainer.value) {
isFollowingBottom = true // 显式回底 = 跟随意图
messagesContainer.value.scrollTo({
top: messagesContainer.value.scrollHeight,
behavior: smooth ? 'smooth' : 'instant',
@@ -2015,24 +2073,31 @@ function scrollToBottom(smooth = false) {
}
}
// 消息/流式变化时:在底部则自动滚;上滑看历史则只刷新回底按钮(不强制打断)
// 消息/流式变化时:跟随意图则自动滚;则只刷新回底按钮(不强制打断)
// 用 isFollowingBottom 锁存而非 isNearBottom 瞬时测量,避免流式高频下单帧突破阈值误判失跟
function onContentChange() {
if (isNearBottom()) {
if (isFollowingBottom) {
nextTick(scrollToBottom)
} else {
refreshBackToBottom()
}
}
watch(() => store.state.messages.length, onContentChange)
watch(() => store.state.currentText, onContentChange)
// SW-260618-07: currentTextonContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行两 handler)
// 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo):
// - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块
// - streaming 翻转:结束清 rAF + 清 streamingBlocks(回 renderMd 走 final marked + 缓存)
// - mdReady 翻转(CR-260615-06):首屏 marked 慢 + 暂无新 delta 时末段停留 escapeFallback
// 纯文本;marked 就绪后主动重算一次,使纯文本转格式化
// SW-260618-07: 合并 currentText 双 watch(原 :2040 onContentChange 滚动 + 本 watch scheduleStreamParse 分块)。
// 顺序:onContentChange(滚动跟随)先 → scheduleStreamParse(rAF 分块渲染)后,保持原注册顺序。
watch(() => store.state.currentText, (text) => {
// B-260618-24: 流式滚动跟随由 scheduleStreamParse 的 rAF 回调承接(DOM 高度就绪后滚),
// 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。
// 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。
if (store.state.streaming && text) scheduleStreamParse(text)
else onContentChange()
})
watch(mdReady, (ready) => {
if (ready && store.state.streaming && store.state.currentText) {
@@ -2155,8 +2220,6 @@ onMounted(async () => {
// pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器
// 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。
window.addEventListener('beforeunload', onBeforeUnloadGuard)
// UX-2025-19: 滚动容器 ref 就绪,补建 IntersectionObserver(模板 sentinel 已在挂载时逐项 observe)
setupVirtualScroll()
// UX-18: 点击外部关闭导出格式菜单
document.addEventListener('click', onExportOutsideClick)
const t0 = (window as any).__APP_T0 ?? 0
@@ -2497,12 +2560,6 @@ interface AiMessageWithStatus extends AiMessage {
status?: string | null
}
/** 判断消息是否"折叠态"(归档段 / 压缩段) */
function isFoldedStatus(msg: AiMessage): boolean {
const s = (msg as AiMessageWithStatus).status
return s === 'archived_segment' || s === 'compressed'
}
/**
* 消息列表分段预处理 — 连续同 status 合并成一条"段"。
*
@@ -2530,17 +2587,18 @@ const messageSegments = computed<MessageSegment[]>(() => {
while (i < msgs.length) {
const m = msgs[i]
const status = (m as AiMessageWithStatus).status
if (status === 'archived_segment' || status === 'compressed') {
// 连续同 status 合并
// F-15 改(2026-06-18):compressed 消息不再折叠,走 normal 照常展开渲染(用户诉求:压缩
// 只影响后端 LLM context 省 token,界面不藏消息)。仅 archived_segment(清空归档)折叠。
// 摘要由后端插首位 system 消息携带,模板 role==='system' 分支置顶渲染。
if (status === 'archived_segment') {
// 连续 archived_segment 合并
const group: AiMessage[] = []
const key = `seg-${m.id}`
while (i < msgs.length && (msgs[i] as AiMessageWithStatus).status === status) {
while (i < msgs.length && (msgs[i] as AiMessageWithStatus).status === 'archived_segment') {
group.push(msgs[i])
i++
}
segments.push(status === 'archived_segment'
? { kind: 'archived', key, msgs: group }
: { kind: 'compressed', key, msgs: group })
segments.push({ kind: 'archived', key, msgs: group })
} else {
segments.push({ kind: 'normal', msg: m })
i++
@@ -2592,39 +2650,30 @@ const renderItems = computed<RenderItem[]>(() => {
})
/**
* 是否存在"活跃消息"(非折叠态)— 控制清空/压缩按钮启用。
* 空会话 / 全部已归档/压缩 → 无活跃消息 → 按钮禁用(不发起无意义 IPC)
* 折叠段内的消息不计(它们已脱离当前上下文)。
* 是否存在"活跃消息"(可清空/可压缩)— 控制清空/压缩按钮启用。
* archived(已清空归档) + compressed(已压缩,后端不再发给 LLM)都算已脱离上下文,不计活跃
* 空会话 / 全归档 / 全压缩 → 无活跃 → 按钮禁用(不发起无意义 IPC)。
*/
const hasActiveMessages = computed(() =>
store.state.messages.some(m => !isFoldedStatus(m)),
store.state.messages.some(m => {
const s = (m as AiMessageWithStatus).status
return s !== 'archived_segment' && s !== 'compressed'
}),
)
// UX-2025-19: 流式末条保活 — streaming 时把末条 AI 消息的 renderItem key 钉进虚拟滚动
// pinned 集合,使其无论是否在可见窗口都渲染内容,保证 ARC-08 块级 memo + streamingBlocks
// v-for + 选区保存/恢复不因虚拟化卸载丢帧。非 streaming 时清空(回归正常虚拟化裁剪)。
const lastStreamingRenderKey = computed<string | null>(() => {
if (!store.state.streaming) return null
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'assistant') return null
// 末条 streaming AI 消息必为 normal 段(renderItems 里 key='m-'+id)
return 'm-' + last.id
})
watch(lastStreamingRenderKey, (key) => {
setPinnedMsgs(key ? new Set([key]) : new Set())
}, { immediate: true })
/**
* 清空当前对话上下文(二次确认 → aiApi.clearContext)。
* 历史消息后端标 archived_segment 归档保留,DB 不删;新对话不受影响。
* 二次确认:不可逆操作,危险按钮显式「清空」(dangerLabel)。
*/
async function handleClearContext(): Promise<void> {
const convId = store.state.activeConversationId
if (!convId) return
if (store.state.streaming) return // 生成中禁操作,避免与流式事件竞争
if (!hasActiveMessages.value) return // 无活跃消息(空/全归档)禁操作
if (!await confirmDialog(t('aiChat.clearContextConfirm'))) return
// 二次确认:不可逆操作,危险按钮显式「清空」(不回退通用「确认」)
if (!await confirmDialog(t('aiChat.clearContextConfirm'), t('aiChat.clearContextDangerLabel'))) return
try {
await store.clearContext()
} catch (e) {
@@ -2805,6 +2854,17 @@ body.ai-sidebar-resizing * {
.ai-conv-group-arrow:not(.is-folded) {
transform: rotate(90deg);
}
.ai-conv-group-count {
margin-left: 2px;
font-weight: 400;
opacity: 0.7;
}
.ai-conv-group-empty {
padding: 2px 8px 4px 20px;
font-size: 10px;
color: var(--df-text-dim);
font-style: italic;
}
.ai-conv-item {
display: flex;
align-items: center;
@@ -3179,8 +3239,8 @@ body.ai-sidebar-resizing * {
flex-direction: column;
gap: 14px;
}
/* UX-2025-19: 虚拟滚动 sentinel 容器 — 取代 msg/sep 成为 .ai-messages 的直接 flex 子
(gap:14px 由它承担,内部消息间距零变化)。占位时 minHeight 锁住上次实测高度防塌。 */
/* UX-2025-19: .ai-messages 的直接 flex 子(B-260618-01:虚拟滚动 sentinel 已移除,
现仅作消息分组的语义容器,gap:14px 由 .ai-messages 承担)。 */
.ai-msg-slot { display: flex; flex-direction: column; }
/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area ── */
@@ -3346,14 +3406,16 @@ body.ai-sidebar-resizing * {
color: var(--df-danger);
border-color: rgba(240,101,101,0.3);
}
/* UX-260617-21:Markdown 表格横向溢出处理。全局 ai-md.css 的 .ai-md table 用 width:100%,
宽表格被压窄单元格换行而非横向滚动;此处覆盖为 max-width:100% + display:block + overflow-x:auto,
使宽表格在气泡内横向滚动不撑破气泡容器,窄表格仍自适应不出现多余滚动条。
深度选择器 :deep() 因 table 经 v-html 注入非 scoped 受控节点。 */
/* B-260618-06:修正 UX-260617-21 的 display:block 方案——display:block 让 table 失去表格
布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。
正解:table 保持默认 display:table(列对齐恢复)+ max-width:100%;宽表格横向滚动交由气泡
容器 .ai-msg-bubble--ai.ai-md 的 overflow-x:auto 承载(CSS spec 中 table-display 下
overflow 被浏览器忽略,滚动须由 block-layout 父容器承担)。:deep() 因 table 经 v-html
注入非 scoped 受控节点。 */
.ai-msg-bubble.ai-md :deep(table) {
display: block;
max-width: 100%;
width: max-content;
}
.ai-msg-bubble--ai.ai-md {
overflow-x: auto;
}
@@ -3913,6 +3975,27 @@ body.ai-sidebar-resizing * {
opacity: 0.6;
}
/* ═══ F-15: 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══
用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开,
摘要 system 块置顶 accent 提示,让用户知情"AI 看到的是摘要"。 */
.ai-msg-system {
margin: 6px 0;
padding: 8px 12px;
border-left: 3px solid var(--df-accent);
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
border-radius: 4px;
font-size: 12px;
}
.ai-msg-system-label {
display: block;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 4px;
font-size: 11px;
}
.ai-msg-system-summary { color: var(--df-text-secondary); line-height: 1.5; }
.ai-msg-system-summary :is(h1, h2, h3) { font-size: 12px; margin: 4px 0 2px; }
/* ═══ F-15 阶段2: 折叠分隔条(归档段 / 压缩段) ═══
连续 archived_segment/compressed 消息合并成一条分隔条;点击展开看原文。
风格对齐 .ai-conv-group-title(dim 小字 + 上下留白),不抢正常消息视觉焦点。 */

View File

@@ -0,0 +1,75 @@
<template>
<!-- 全局错误兜底:子树渲染/生命周期抛错时显示降级 UI,防止白屏
用法: <ErrorBoundary> 包裹可能出错的子组件树(slot 透传) -->
<template v-if="error">
<div class="error-boundary">
<div class="error-boundary-icon"></div>
<div class="error-boundary-title">{{ $t('error.title') }}</div>
<div class="error-boundary-desc">{{ $t('error.desc') }}</div>
<button class="error-boundary-retry" @click="reset">{{ $t('error.retry') }}</button>
</div>
</template>
<slot v-else />
</template>
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
// errorCaptured 捕获子组件树抛出的错误(渲染/生命周期),返回 false 阻止继续向上冒泡
const error = ref<unknown>(null)
onErrorCaptured((err) => {
error.value = err
console.error('[ErrorBoundary] 子树渲染错误:', err)
return false
})
// 重试:重置 errorState,slot 重新挂载子树
function reset() {
error.value = null
}
</script>
<style scoped>
.error-boundary {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 16px;
color: var(--df-text-secondary, #888);
text-align: center;
}
.error-boundary-icon {
font-size: 28px;
color: var(--df-danger, #e5484d);
line-height: 1;
}
.error-boundary-title {
font-size: 14px;
font-weight: 500;
color: var(--df-text, #333);
}
.error-boundary-desc {
font-size: 12px;
line-height: 1.5;
max-width: 360px;
}
.error-boundary-retry {
margin-top: 8px;
padding: 5px 16px;
font-size: 12px;
font-weight: 500;
font-family: var(--df-font-sans);
background: var(--df-accent, #7b6ff0);
color: #fff;
border: none;
border-radius: var(--df-radius-sm, 4px);
cursor: pointer;
transition: filter 0.15s;
}
.error-boundary-retry:hover {
filter: brightness(1.1);
}
</style>

View File

@@ -3,7 +3,7 @@
class="ai-tool-card"
:class="[
'ai-tool-card--' + tc.status,
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isToolFailure(tc) },
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isFailed },
]"
>
<!-- 卡片头部图标 + 名称 + 状态 -->
@@ -14,10 +14,10 @@
<div class="ai-tool-header-text">
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
<span v-else-if="isToolFailure(tc)" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isToolFailure(tc) ? 'failed' : tc.status)"></span>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)"></span>
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }"></span>
</div>
@@ -73,9 +73,11 @@
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.lines || 0 }} {{ $t('aiTool.lines') }} · {{ formatBytes(parsed?.size) }}</span>
<span class="ai-tool-file-meta">{{ parsed?.has_more
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
<button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expandAll') }}
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
</button>
</div>
<pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
@@ -87,6 +89,7 @@
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: 1000 }) }}</span>
</div>
<div class="ai-tool-dir-entries">
<div
@@ -103,20 +106,72 @@
</div>
</div>
<!-- search_files 结果:匹配文件列表(对齐 list_directory 渲染, results 被丢弃致 body 仅显找到 N ) -->
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">{{ parsed?.pattern }}</span>
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
</div>
<div class="ai-tool-dir-entries">
<div v-for="(entry, i) in parsed?.results" :key="i" class="ai-tool-dir-entry">
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
<span class="ai-tool-dir-name">{{ entry.path }}</span>
<span class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
</div>
</div>
</div>
<!-- write_file 结果 -->
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="ai-tool-write-path">{{ parsed?.path }}</span>
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
</div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
UX-260618-06:后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 此分支渲染 diff -->
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason"> {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
</div>
</div>
<!-- run_command 结果:命令行常显 + 可折叠输出(执行后仍能看到跑了什么命令) -->
<div
v-else-if="tc.name === 'run_command' && tc.result && tc.status === 'completed'"
class="ai-tool-cmd-result"
>
<div class="ai-tool-cmd-line" :class="{ 'ai-tool-cmd-line--failed': isFailed }">
<span class="ai-tool-cmd-prompt">$</span>
<code class="ai-tool-cmd-text">{{ argString(tc.args, 'command') }}</code>
<button
v-if="cmdOutput"
class="ai-tool-cmd-toggle"
:title="cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
@click="cmdOutputExpanded = !cmdOutputExpanded"
>{{ cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
</div>
<!-- 通用 CRUD 结果 -->
<div
v-else-if="tc.result && tc.status === 'completed'"
class="ai-tool-result"
:class="{ 'ai-tool-result--failed': isToolFailure(tc) }"
:class="{ 'ai-tool-result--failed': isFailed }"
>
<div v-if="isToolFailure(tc)" class="ai-tool-failed-banner"> {{ $t('aiTool.executionFailedHint') }}</div>
<div v-if="isFailed" class="ai-tool-failed-banner"> {{ $t('aiTool.executionFailedHint') }}</div>
<code>{{ formatToolResult(tc) }}</code>
</div>
</div><!-- /.ai-tool-body -->
@@ -146,6 +201,7 @@ const t = ((i18n as any).global.t as (k: string, p?: Record<string, unknown>) =>
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
interface ToolResult {
path?: string
pattern?: string
from?: string
to?: string
content?: string
@@ -184,6 +240,20 @@ interface ToolResult {
has_more?: boolean
warning?: string
review_rounds?: number
// UX-260618-05~12:补全后端返回前端消费的字段(review_rounds/truncated/bytes_moved/has_more/total/entries 已存在)
task_id?: string
execution_id?: string
target_status?: string
note?: string
diff?: string
matches_found?: number
backup_path?: string
returned_lines?: number
items?: Array<Record<string, unknown>>
modified?: number | null
old_size?: number | null
encoding?: string
cross_volume?: boolean
}
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
@@ -239,6 +309,12 @@ function formatToolName(name: string): string {
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
/** run_command 命令首行截断(头部展示;多行命令取首行,超长省略) */
function shortCmd(cmd: string, max = 48): string {
const firstLine = cmd.split('\n')[0].replace(/\s+/g, ' ').trim()
return firstLine.length > max ? firstLine.slice(0, max) + '…' : firstLine
}
function formatJson(data: unknown): string {
try {
return JSON.stringify(data, null, 2)
@@ -365,6 +441,30 @@ function formatToolResult(tc: AiToolCallInfo): string {
return raw
}
switch (tc.name) {
case 'list_projects':
case 'list_tasks':
case 'list_ideas':
case 'list_trash': {
// UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组),渲染结构化列表替代裸 JSON
const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : [])
const total = typeof r.total === 'number' ? r.total : items.length
const head = tc.name === 'list_projects' ? t('aiTool.projectCount', { n: total })
: tc.name === 'list_tasks' ? t('aiTool.taskCount', { n: total })
: tc.name === 'list_ideas' ? t('aiTool.ideaCount', { n: total })
: t('aiTool.trashCount', { n: total })
const lines = items.slice(0, 10).map((it: Record<string, unknown>) => {
const label = typeof it.name === 'string' ? it.name
: typeof it.title === 'string' ? it.title
: typeof it.id === 'string' ? it.id
: ''
const status = typeof it.status === 'string' ? ` [${it.status}]` : ''
const stack = typeof it.stack === 'string' ? ` (${it.stack})` : ''
return label ? `- ${label}${status}${stack}` : ''
}).filter(Boolean)
const parts = [head, ...lines]
if (r.has_more) parts.push(t('aiTool.listHasMore', { n: items.length }))
return parts.join('\n')
}
case 'write_file':
return `${r.path} (${formatBytes(r.bytes_written)})`
case 'update_task':
@@ -372,13 +472,16 @@ function formatToolResult(tc: AiToolCallInfo): string {
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
: t('aiTool.updated')
case 'advance_task':
return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
// UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音)
return typeof r.review_rounds === 'number' && r.review_rounds > 0
? t('aiTool.advancedTaskWithRounds', { id: r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
case 'delete_task':
return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
case 'delete_file':
return r.permanent
? t('aiTool.deleteFilePermanent', { path: r.path || '' })
: t('aiTool.deleteFileSoft', { path: r.path || '' })
: t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' })
case 'run_command':
return formatCommandResult(r)
case 'patch_file':
@@ -389,7 +492,9 @@ function formatToolResult(tc: AiToolCallInfo): string {
return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
case 'rename_file':
return r.renamed
? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' })
? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: formatJson(r)
case 'search_files': {
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0)
@@ -401,6 +506,14 @@ function formatToolResult(tc: AiToolCallInfo): string {
return r.bound
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
: formatJson(r)
case 'run_workflow': {
// UX-260618-05: 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
if (!execId) return formatJson(r)
return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId })
}
default:
return formatJson(r)
}
@@ -435,13 +548,18 @@ function formatCommandResult(r: ToolResult): string {
return parts.join('\n')
}
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数 */
function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } {
/** SW-260618-19: 合并 stdout/stderr(空串跳过),供 cmdOutput 展开 + combineAndTruncateLines 截断共享 DRY */
function combineOutputs(stdout: string | undefined, stderr: string | undefined): string {
const parts: string[] = []
if (stdout) parts.push(stdout)
if (stderr) parts.push(stderr)
if (!parts.length) return { text: '', truncated: false, total: 0 }
const combined = parts.join('\n')
return parts.join('\n')
}
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数(合并复用 combineOutputs) */
function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } {
const combined = combineOutputs(stdout, stderr)
if (!combined) return { text: '', truncated: false, total: 0 }
const allLines = combined.split('\n')
const total = allLines.length
if (total <= maxLines) {
@@ -458,6 +576,10 @@ function formatFileInfoResult(r: ToolResult): string {
}
let base = t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
const extras: string[] = []
// UX-260618-12: file_info modified 时间(epoch_ms → 客户端 toLocaleString)
if (typeof r.modified === 'number' && r.modified > 0) {
extras.push(t('aiTool.fileInfoModified', { time: new Date(r.modified).toLocaleString() }))
}
if (r.is_dir) {
extras.push(t('aiTool.fileInfoDir'))
} else {
@@ -505,6 +627,12 @@ const emit = defineEmits<{
approve: [{ id: string; approved: boolean }]
}>()
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
// 注:此处仅状态翻转时调一次,不进模板高频路径,沿用模块级 isToolFailure(props.tc) 无性能问题。
const cmdOutputExpanded = ref(false)
// SW-260618-06: cmdOutputExpanded 的 status watch 合并到下方 approving 复位 watch
// (approving/approvingTimer 在下方定义,此处 immediate 会 TDZ,合并 watch 放三 ref 都已定义后)。
/**
* 审批按钮 loading 态(B-260616-08)。
* 点击后置 true,禁用两按钮防重复点击并显示 spinner。后端回事件 useAiEvents 把 tc.status
@@ -570,13 +698,16 @@ async function onApprove(approved: boolean) {
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: props.tc.id, approved })
}
// status 离开 pending_approval → 后端已回事件 → 复位 loading 并清计时器
// SW-260618-06: 合并双 watch(原 cmdOutputExpanded watch + approving 复位 watch,同源 props.tc.status)。
// immediate 时 s=初始 status:cmdOutputExpanded 仅 completed 初始化(初始非 completed 无副作用);
// approving 初始 false + approvingTimer 初始 null(if null 跳过 clearTimeout),immediate 安全。
watch(() => props.tc.status, (s) => {
if (s === 'completed') cmdOutputExpanded.value = isToolFailure(props.tc)
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
}
})
}, { immediate: true })
// 组件卸载清计时器防泄漏
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
@@ -586,6 +717,34 @@ onBeforeUnmount(() => {
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
const parsed = computed(() => parseResult(props.tc.result))
/**
* 是否工具失败(completed 语义)。模板多处复用(.ai-tool-card--failed / 状态点 / 失败横幅等,
* 6+ 引用)。原每处直调 isToolFailure(tc) → 每次 patch 重新 parseResult + 正则,
* 现下沉为 computed 复用 parsed,patch 间不重算。逻辑与模块级 isToolFailure 同源。
*/
const isFailed = computed(() => {
const tc = props.tc
if (tc.status !== 'completed') return false
const r = parsed.value
if (tc.name === 'run_command') {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
if (r) return true
}
if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
})
/** run_command 展开态全量输出(stdout+stderr 合并);空串隐藏 toggle。复用 parsed 不重 parse */
const cmdOutput = computed(() => {
const r = parsed.value
if (!r) return ''
return combineOutputs(r.stdout, r.stderr)
})
/**
* AE-2025-03: write_file 审批 diff 行解析。
* 后端 generate_diff 输出 '+新行/-旧行/ 上下文行',逐行拆 → {kind, text} 供模板按 kind 着色。
@@ -603,6 +762,29 @@ const diffLines = computed(() => {
}).filter((x): x is { kind: string; text: string } => x !== null)
})
/**
* UX-260618-06: patch_file 结果 diff 行拆解(复用 diffLines 同款 +add/-del/ ctx 着色)。
* 数据源不同:diffLines 取 props.tc.diff(审批事件注入,仅 write_file);此 computed 取
* parsed.diff(patch_file 工具执行结果字段)。两条路径互不干扰。
* 长 diff 截断到前 120 行防撑爆卡片(unified diff 含上下文行,大改可能很长)。
*/
const resultDiffLines = computed(() => {
const d = parsed.value?.diff
if (!d) return []
const MAX = 120
const lines = d.split('\n').map(line => {
if (line === '') return null
if (line.startsWith('+')) return { kind: 'add', text: line }
if (line.startsWith('-')) return { kind: 'del', text: line }
return { kind: 'ctx', text: line }
}).filter((x): x is { kind: string; text: string } => x !== null)
if (lines.length > MAX) {
lines.splice(MAX)
lines.push({ kind: 'ctx', text: '…' })
}
return lines
})
/**
* 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
* 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。
@@ -665,6 +847,11 @@ function toolDisplayName(tc: AiToolCallInfo): string {
}
case 'create_task': return t('aiTool.createTask')
case 'create_project': return t('aiTool.createProject')
case 'run_command': {
// 折叠态头部也显命令(取首行+截断),无 command 回退通用名
const cmd = argString(tc.args, 'command')
return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name)
}
default: return formatToolName(tc.name)
}
}
@@ -679,9 +866,21 @@ function toolResultSummary(tc: AiToolCallInfo): string {
const r = parseResult(tc.result)
if (!r) return ''
switch (tc.name) {
case 'list_tasks': return Array.isArray(r) ? t('aiTool.taskCount', { n: r.length }) : ''
case 'list_projects': return Array.isArray(r) ? t('aiTool.projectCount', { n: r.length }) : ''
case 'list_ideas': return Array.isArray(r) ? t('aiTool.ideaCount', { n: r.length }) : ''
case 'list_tasks':
case 'list_projects':
case 'list_ideas':
case 'list_trash': {
// UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数
const n = Array.isArray(r) ? r.length
: (typeof r.total === 'number' ? r.total
: (Array.isArray(r.items) ? r.items.length : 0))
if (!n) return ''
const key = tc.name === 'list_projects' ? 'projectCount'
: tc.name === 'list_tasks' ? 'taskCount'
: tc.name === 'list_ideas' ? 'ideaCount'
: 'trashCount'
return t(`aiTool.${key}`, { n })
}
case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created')
case 'create_idea':
case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created')
@@ -689,12 +888,14 @@ function toolResultSummary(tc: AiToolCallInfo): string {
case 'update_task': return r.updated !== false
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
: t('aiTool.updated')
case 'advance_task': return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
case 'advance_task': return typeof r.review_rounds === 'number' && r.review_rounds > 0
? t('aiTool.advancedTaskWithRounds', { id: r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
case 'delete_project':
case 'delete_task': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
case 'delete_file': return r.permanent
? t('aiTool.deleteFilePermanent', { path: r.path || '' })
: t('aiTool.deleteFileSoft', { path: r.path || '' })
: t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' })
case 'restore_project': return r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective')
case 'purge_project': return r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective')
case 'run_command': {
@@ -707,7 +908,9 @@ function toolResultSummary(tc: AiToolCallInfo): string {
: t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) })
case 'append_file': return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
case 'rename_file': return r.renamed
? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' })
? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: ''
case 'search_files': {
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0)
@@ -719,7 +922,13 @@ function toolResultSummary(tc: AiToolCallInfo): string {
case 'bind_directory': return r.bound
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
: ''
case 'run_workflow': return t('aiTool.workflowHint')
case 'run_workflow': {
// UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
}
default: return ''
}
}
@@ -1204,6 +1413,64 @@ function toolResultSummary(tc: AiToolCallInfo): string {
word-break: break-all;
}
/* -- run_command 结果:命令行(常显) + 可折叠输出 -- */
.ai-tool-cmd-result {
margin: 0;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-cmd-line {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(61,219,160,0.04);
}
.ai-tool-cmd-line--failed {
background: var(--df-danger-bg);
}
.ai-tool-cmd-prompt {
flex-shrink: 0;
font-weight: 600;
color: var(--df-success);
}
.ai-tool-cmd-line--failed .ai-tool-cmd-prompt {
color: var(--df-danger);
}
.ai-tool-cmd-text {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--df-text);
}
.ai-tool-cmd-toggle {
flex-shrink: 0;
padding: 2px 4px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
font-size: 10.5px;
cursor: pointer;
}
.ai-tool-cmd-toggle:hover {
color: var(--df-text);
background: var(--df-bg-hover, rgba(255,255,255,0.06));
}
.ai-tool-cmd-output {
margin: 0;
padding: 8px 10px;
max-height: 160px;
overflow-y: auto;
background: rgba(0,0,0,0.18);
color: var(--df-text-dim);
white-space: pre-wrap;
word-break: break-all;
border-top: 0.5px solid var(--df-border);
}
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast {
position: absolute;

View File

@@ -1,9 +1,9 @@
<template>
<div class="ai-tool-calls" ref="rootEl">
<!-- 顶部操作栏批量审批 + 全局收起合并同(UX-260616-02,减少垂直空间占用) -->
<div v-if="pendingCount > 0 || collapsibleGroupCount > 0" class="ai-tool-topbar">
<!-- 顶部操作栏批量审批(全局收起已下沉到首个可折叠分组标题,U-260618) -->
<div v-if="pendingCount > 0" class="ai-tool-topbar">
<!-- 批量操作栏存在待审批项时显示 -->
<div v-if="pendingCount > 0" class="ai-batch-approve">
<div class="ai-batch-approve">
<button class="ai-btn ai-btn--sm" @click="$emit('batch-approve', 'approve')">
{{ $t('aiTool.approveAll', { n: pendingCount }) }}
</button>
@@ -11,12 +11,6 @@
{{ $t('aiTool.rejectAll') }}
</button>
</div>
<!-- 全部展开 / 全部收起仅当存在可折叠分组时显示推到行尾右对齐 -->
<div v-if="collapsibleGroupCount > 0" class="ai-tool-global-toggle" @click="toggleAllGroups">
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
</div>
</div>
<template v-for="(group, gi) in groupedToolCalls" :key="group.name">
@@ -26,6 +20,15 @@
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<span class="ai-tool-group-count">{{ group.calls.length }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }"></span>
<!-- 全部展开/收起(U-260618: topbar 下沉到首个可折叠分组标题行尾巴, run command 同行) -->
<div
v-if="gi === firstCollapsibleGi"
class="ai-tool-global-toggle"
@click.stop="toggleAllGroups"
>
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
</div>
</div>
<ToolCard
v-for="(tc, ci) in group.calls"
@@ -33,7 +36,7 @@
:tc="tc"
:isExpanded="isCardExpanded(tc, gi, ci)"
:isContentExpanded="expandedTools.has(tc.id)"
@toggle="toggleCardExpand"
@toggle="() => toggleCardExpand(tc, gi, ci)"
@expand-content="toggleExpand"
@approve="(e) => emit('approve', e)"
:class="{ 'ai-tool-card--group-hidden': group.calls.length >= 2 && isGroupCollapsed(gi) }"
@@ -72,6 +75,9 @@ const expandedCards = ref(new Set<string>())
// userExpandedCards 是"用户明确点开"的持久标记,collapseInactive 跳过这些卡。
// 语义:用户读过的卡不强制折叠,符合"跟随阅读位置"的增强目标。
const userExpandedCards = ref(new Set<string>())
// UX-260617-23:用户主动折叠记忆(修复首卡 ci===0 兜底 return true 导致无法折叠)。
// isCardExpanded 最优先判断,覆盖默认首卡展开 + userExpandedCards 记忆。
const userCollapsedCards = ref(new Set<string>())
// 内容级展开read_file "展开全部"
const expandedTools = ref(new Set<string>())
// 分组折叠状态(按分组索引);多卡片分组默认收起
@@ -108,6 +114,14 @@ const collapsibleGroupCount = computed(() =>
groupedToolCalls.value.filter(g => g.calls.length >= 2).length
)
/** 首个可折叠分组索引(U-260618:全部展开/收起按钮下沉到此分组标题行尾巴) */
const firstCollapsibleGi = computed(() => {
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) return i
}
return -1
})
/** 待审批工具数status === 'pending_approval'),控制批量操作栏显隐 */
const pendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
@@ -207,6 +221,8 @@ function groupDisplayName(group: ToolCallGroup): string {
*/
function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
if (isGroupCollapsed(gi)) return false
// UX-260617-23:用户主动折叠最优先(覆盖默认首卡 ci===0 兜底展开 + 用户展开记忆)
if (userCollapsedCards.value.has(tc.id)) return false
// UX-260616-03:用户主动展开的卡片记忆态优先(不被自动收起影响)
if (userExpandedCards.value.has(tc.id)) return true
// 用户手动切换过该卡(当前态)
@@ -217,15 +233,21 @@ function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
return true
}
function toggleCardExpand(id: string) {
if (expandedCards.value.has(id)) {
function toggleCardExpand(tc: AiToolCallInfo, gi: number, ci: number) {
const id = tc.id
// 用 isCardExpanded 判当前真实态(含默认首卡展开),而非 expandedCards(默认首卡不在其中,
// 否则首卡点 toggle 会误判"当前折叠"反向展开)
const currentlyExpanded = isCardExpanded(tc, gi, ci)
if (currentlyExpanded) {
// 展开 → 折叠
expandedCards.value.delete(id)
// 收起时同步清记忆(下次自动收起可作用于它)
userExpandedCards.value.delete(id)
userCollapsedCards.value.add(id)
} else {
// 折叠 → 展开
expandedCards.value.add(id)
// UX-260616-03:展开即记忆(用户主动展开,后续不被自动收起)
userExpandedCards.value.add(id)
userCollapsedCards.value.delete(id)
}
}
@@ -313,24 +335,21 @@ function groupIcon(name: string): string {
font-size: 11px;
}
/* -- 全部展开/收起按钮 -- */
/* -- 全部展开/收起(U-260618:从 topbar 下沉到分组标题行,移除独立按钮 padding/背景,
高度=文字行高,不撑高分组标题行;融入标题视觉,仅 hover 变色) -- */
.ai-tool-global-toggle {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--df-text-dim);
border-radius: var(--df-radius-sm);
transition: background 0.15s var(--df-ease);
width: fit-content;
/* UX-260616-02: 合并同行时推到行尾右对齐(batch-approve 在左,收起在右) */
transition: color 0.15s var(--df-ease);
/* 推到分组标题行尾巴右对齐 */
margin-left: auto;
}
.ai-tool-global-toggle:hover {
background: rgba(255,255,255,0.03);
color: var(--df-text-secondary);
}
.ai-tool-global-toggle-icon {
@@ -344,6 +363,8 @@ function groupIcon(name: string): string {
align-items: center;
gap: 6px;
padding: 6px 10px;
/* U-260618: max-width 对齐气泡/卡片(90%),不拉满 ai-msg-content 右侧 */
max-width: var(--df-msg-max-width);
cursor: pointer;
user-select: none;
border-radius: var(--df-radius-sm);
@@ -378,7 +399,7 @@ function groupIcon(name: string): string {
color: var(--df-accent);
}
.ai-tool-group-chevron {
margin-left: auto;
/* U-260618: 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
font-size: 10px;
color: var(--df-text-dim);
transition: transform 0.15s var(--df-ease);

View File

@@ -264,8 +264,11 @@ onMounted(() => {
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig()
// F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值)
// F-260618-23: App.vue 根 onMounted 已在应用启动时恢复(覆盖用户没先进 Settings 直接用 AI Chat 的场景),
// 此处保留双保险IPC 幂等值相同无害)
syncAgentMaxIterations()
// F-260616-07: 启动同步一次流式失败重试次数(防刷新页面后后端未恢复用户设定值)
// F-260618-23: 同上App.vue 根 onMounted 已恢复,此处双保险保留
syncAgentMaxRetries()
})

View File

@@ -111,8 +111,6 @@
<div class="model-tags">
<span v-for="md in m.modalities" :key="'md-' + md" class="tag tag-modality">{{ $t('settings.tagModality.' + md) }}</span>
<span v-for="cap in m.capabilities" :key="'cap-' + cap" class="tag tag-capability">{{ $t('settings.tagCapability.' + cap) }}</span>
<span class="tag tag-cost" :class="'tag-cost--' + m.cost_tier">{{ $t('settings.tagCost.' + m.cost_tier) }}</span>
<span class="tag tag-intel" :class="'tag-intel--' + m.intelligence">{{ $t('settings.tagIntel.' + m.intelligence) }}</span>
<span v-if="m.probe_source" class="tag tag-source">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
</div>
</div>
@@ -250,6 +248,7 @@ async function saveProvider() {
baseUrl: providerForm.baseUrl,
apiKey: providerForm.apiKey,
defaultModel: providerForm.defaultModel,
modelConfigs: providerForm.models,
})
providerForm.visible = false
await loadProviders()