优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)
This commit is contained in:
15
src/App.vue
15
src/App.vue
@@ -113,6 +113,7 @@ import { useProjectStore } from './stores/project'
|
||||
import { useAppSettingsStore } from './stores/appSettings'
|
||||
import i18n from './i18n'
|
||||
import AiChat from './components/AiChat.vue'
|
||||
import { aiApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const isDetached = computed(() => route.path === '/ai-detached')
|
||||
@@ -207,6 +208,20 @@ onMounted(async () => {
|
||||
await migrateLegacyLocalStorage()
|
||||
// 3. 缓存已就绪,初始化主题/locale
|
||||
initTheme()
|
||||
// F-260618-23: 根 onMounted 恢复 Agentic 轮次/重试到后端(AtomicUsize 纯内存,重启回默认)。
|
||||
// GeneralPanel.vue onMounted 仅 Settings 页挂载,用户直接进 AI Chat 不同步 → 后端默认 10 与前端显示持久值不一致。
|
||||
// clamp 与 GeneralPanel.syncAgentMaxIterations/syncAgentMaxRetries 逐字对齐(双保险,IPC 幂等值相同无害)
|
||||
// 包 try/catch 防 IPC 失败 reject onMounted(对齐 dataChangedListener 模式)
|
||||
try {
|
||||
const iter = appSettings.get<number>('df-ai-agent-max-iterations', 10)
|
||||
const iterClamped = Math.min(50, Math.max(1, iter || 10))
|
||||
const retry = appSettings.get<number>('df-ai-agent-max-retries', 3)
|
||||
const retryClamped = Math.min(10, Math.max(0, retry ?? 3))
|
||||
await aiApi.setAgentMaxIterations(iterClamped)
|
||||
await aiApi.setAgentMaxRetries(retryClamped)
|
||||
} catch (e) {
|
||||
console.error('恢复 Agentic 轮次/重试配置失败:', e)
|
||||
}
|
||||
const savedLang = appSettings.get<string | null>('df-language', null)
|
||||
if (savedLang) {
|
||||
i18n.global.locale.value = savedLang as 'zh-CN' | 'en'
|
||||
|
||||
@@ -128,6 +128,7 @@ export const aiApi = {
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
defaultModel: string
|
||||
modelConfigs: ModelConfig[]
|
||||
}): Promise<string> {
|
||||
return invoke('ai_save_provider', {
|
||||
id: input.id,
|
||||
@@ -136,6 +137,7 @@ export const aiApi = {
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: input.apiKey,
|
||||
defaultModel: input.defaultModel,
|
||||
modelConfigs: input.modelConfigs,
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -203,10 +203,10 @@ export type Modality = 'text' | 'vision' | 'audio' | 'video'
|
||||
/** 维度 2 — 能力(模型能做什么)。serde snake_case 字面量。 */
|
||||
export type Capability = 'tool_use' | 'embedding' | 'code_gen'
|
||||
|
||||
/** 维度 3 — 价格分级(成本)。serde snake_case 字面量。 */
|
||||
export type CostTier = 'free' | 'low' | 'medium' | 'high'
|
||||
/** 维度 3 — 价格分级(成本)。serde snake_case 字面量。'free' 已删(B-260618-05 死档,预设/启发式从不赋值)。 */
|
||||
export type CostTier = 'low' | 'medium' | 'high'
|
||||
|
||||
/** 维度 4 — 智力分级。serde snake_case 字面量。 */
|
||||
/** 维度 4 — 智力分级。serde snake_case 字面量。CR-260618-11#3:与 CostTier 同,cost/intel 已从路由硬过滤(B-260618-03)及前端标签(UX-260618-04)去除,字段保留仅 DB 兼容,不再参与路由选模型。 */
|
||||
export type IntelligenceTier = 'lite' | 'standard' | 'plus' | 'ultra'
|
||||
|
||||
/** 探测来源(只读,由后端探测器填充)。serde snake_case 字面量。 */
|
||||
@@ -300,7 +300,7 @@ export type ContentPart =
|
||||
/** AI 消息(前端渲染用,isError 标记错误消息用于差异化样式) */
|
||||
export interface AiMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'tool'
|
||||
role: 'user' | 'assistant' | 'tool' | 'system'
|
||||
content: string
|
||||
isError?: boolean
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
|
||||
@@ -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: currentText → onContentChange 合并到下方 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 小字 + 上下留白),不抢正常消息视觉焦点。 */
|
||||
|
||||
75
src/components/ErrorBoundary.vue
Normal file
75
src/components/ErrorBoundary.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -22,6 +22,26 @@ const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>
|
||||
async function loadConversations() {
|
||||
try {
|
||||
state.conversations = await aiApi.listConversations()
|
||||
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
|
||||
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
|
||||
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
|
||||
// 首条消息落库后 AiCompleted/AiError 触发的 loadConversations 拉到同 id 真实记录,虚拟项被替代无重复。
|
||||
if (state.activeConversationId
|
||||
&& !state.conversations.some(c => c.id === state.activeConversationId)) {
|
||||
const now = String(Date.now())
|
||||
state.conversations.unshift({
|
||||
id: state.activeConversationId,
|
||||
title: null,
|
||||
provider_id: null,
|
||||
model: null,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
|
||||
const pending = appSettings.get<string | null>('df-ai-active-conv', null)
|
||||
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
|
||||
@@ -100,7 +120,8 @@ export async function switchConversation(id: string) {
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
model: m.model,
|
||||
timestamp: Date.now(),
|
||||
// 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now()
|
||||
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
|
||||
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
|
||||
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
|
||||
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
|
||||
@@ -230,6 +251,12 @@ function toggleArchivedFold() {
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/** 折叠/展开时间分组(today/yesterday/earlier) */
|
||||
function toggleGroupFold(key: string) {
|
||||
state.foldedGroups[key] = !state.foldedGroups[key]
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/** 展开/收起侧栏(会话列表) */
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen
|
||||
@@ -246,6 +273,7 @@ export function useAiConversations() {
|
||||
archiveConversation,
|
||||
setPinnedConversation,
|
||||
toggleArchivedFold,
|
||||
toggleGroupFold,
|
||||
toggleSidebar,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ function applyUiState(s: any) {
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
if (s.foldedGroups && typeof s.foldedGroups === 'object') {
|
||||
state.foldedGroups = { ...state.foldedGroups, ...s.foldedGroups }
|
||||
}
|
||||
// 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底)
|
||||
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
|
||||
}
|
||||
@@ -63,6 +66,7 @@ export function persistUiState() {
|
||||
maximized: state.maximized,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
archivedCollapsed: state.archivedCollapsed,
|
||||
foldedGroups: state.foldedGroups,
|
||||
// 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
})
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* useAiVirtualScroll — AiChat 消息列表虚拟滚动(IntersectionObserver 可见窗口懒渲染)。
|
||||
*
|
||||
* 设计取舍(UX-2025-19):选自研 IntersectionObserver 而非 vue-virtual-scroller。
|
||||
* - 零新依赖(项目 package.json 无 vue-virtual-scroller)。
|
||||
* - vue-virtual-scroller DynamicScroller 会接管滚动容器 + 重排子节点,
|
||||
* 破坏 .ai-messages 现有 flex/gap 布局、onMessagesScroll 边沿收起(wasNearBottom)、
|
||||
* isNearBottom/scrollToBottom 计算等"渲染层"以外的既有逻辑。
|
||||
* - 自研方案只做"渲染层裁剪":可见窗口外的 item 内容卸载、保留占位高度,
|
||||
* scrollHeight/scrollTop 计算与既有滚动逻辑零感知。
|
||||
*
|
||||
* 工作机制:
|
||||
* 1. 每个列表项包一个 sentinel div(始终挂载,只承担高度占位 + IO 观测)。
|
||||
* 2. IntersectionObserver root=滚动容器,rootMargin 上下各预加载一段(默认 600px),
|
||||
* 保证滚动时内容提前就位,不出现空白闪烁。
|
||||
* 3. sentinel 进入预加载区→key 进 renderedKeys→内容渲染;离开→移除→内容卸载,
|
||||
* 但 sentinel 自身保留(其已测得的高度经 inline style 占位,scrollHeight 不塌)。
|
||||
* 4. 高度测量:ResizeObserver 观测 sentinel(内容挂载时其高度=内容高度),
|
||||
* 卸载前把最后一次实测高度写进 inline style,卸载后 sentinel 仍占该高度。
|
||||
* 5. 流式末条保活:pinnedKeys 中的 key 永远视为"可见",不虚拟化(见 AiChat.vue 调用点
|
||||
* 把 streaming 末条 AI 消息 key 传入),保证流式渲染(ARC-08 块级 memo +
|
||||
* streamingBlocks v-for + 选区保存/恢复)不因虚拟化丢帧。
|
||||
*
|
||||
* 不处理(留观察,见诚实拆波):
|
||||
* - ToolCard 嵌套内部的虚拟化(单条消息内多工具卡,当前不裁剪,首屏内条目少)。
|
||||
* - 编辑光标在 user 消息上的保持(UX-09 走 input 框回填,与列表虚拟化无耦合)。
|
||||
*/
|
||||
import { ref, onBeforeUnmount, type Ref } from 'vue'
|
||||
|
||||
export interface AiVirtualScrollOptions {
|
||||
/** 滚动容器(IntersectionObserver root) */
|
||||
root: Ref<HTMLElement | null | undefined>
|
||||
/** 上下预加载像素数(可见区外提前渲染的高度,防滚出空白)。 */
|
||||
rootMarginPx?: number
|
||||
}
|
||||
|
||||
export function useAiVirtualScroll(options: AiVirtualScrollOptions) {
|
||||
const rootMargin = options.rootMarginPx ?? 600
|
||||
// 当前"应当渲染内容"的 key 集合(IO 判定可见 + pinned)。模板读这个判断渲染与否。
|
||||
const renderedKeys = ref<Set<string>>(new Set())
|
||||
// 永远渲染的 key(流式末条等),由调用方维护传入。
|
||||
const pinnedKeys = ref<Set<string>>(new Set())
|
||||
|
||||
// 每个观测项的元数据:DOM 元素 + 最近实测高度(卸载后占位用)。
|
||||
const itemMeta = new Map<string, { el: HTMLElement; height: number }>()
|
||||
let io: IntersectionObserver | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
function ensureObserver(): void {
|
||||
if (io) return
|
||||
const rootEl = options.root.value
|
||||
if (!rootEl) return // root 未就绪;setupOnMount(root ref 就绪后)会再调一次建观察器
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const next = new Set(renderedKeys.value)
|
||||
let changed = false
|
||||
for (const e of entries) {
|
||||
const key = (e.target as HTMLElement).dataset.vscrollKey
|
||||
if (!key) continue
|
||||
const visible = e.isIntersecting
|
||||
const has = next.has(key)
|
||||
if (visible && !has) {
|
||||
// 重新可见:清掉卸载时锁的占位 minHeight,让内容自然撑高(RO 再回填真实高度)
|
||||
const meta = itemMeta.get(key)
|
||||
if (meta && meta.el.isConnected) meta.el.style.minHeight = ''
|
||||
next.add(key); changed = true
|
||||
}
|
||||
else if (!visible && has && !pinnedKeys.value.has(key)) {
|
||||
// 卸载前:把实测高度锁到 sentinel 的 minHeight,防内容移除后 sentinel 塌成 0
|
||||
// → scrollHeight 不变 → isNearBottom/scrollToBottom/回到底部计算零感知。
|
||||
// 内容重新挂载时 RO 会把真实高度回填,registerSentinel 复挂时清 minHeight 由真实高度接管。
|
||||
// 防御(P0):RO 首次回调前 item 已被 IO 判不可见时 meta.height===0,直接不设 minHeight
|
||||
// → sentinel 塌 0 高 → 下条消息上浮重叠。此处兜底:height>0 用实测值,
|
||||
// height===0 用 el.offsetWidth 量得的实际渲染高度(此时 el 仍在 DOM),仍<=0 则用固定 40px。
|
||||
const meta = itemMeta.get(key)
|
||||
if (meta && meta.el.isConnected) {
|
||||
const measured = meta.height > 0 ? meta.height : (meta.el.offsetHeight || 0)
|
||||
const placeholder = measured > 0 ? measured : 40
|
||||
meta.el.style.minHeight = `${placeholder}px`
|
||||
}
|
||||
next.delete(key); changed = true
|
||||
}
|
||||
}
|
||||
if (changed) renderedKeys.value = next
|
||||
},
|
||||
{ root: rootEl, rootMargin: `${rootMargin}px 0px ${rootMargin}px 0px`, threshold: 0 },
|
||||
)
|
||||
ro = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const key = (e.target as HTMLElement).dataset.vscrollKey
|
||||
if (!key) continue
|
||||
const meta = itemMeta.get(key)
|
||||
if (meta) meta.height = e.contentRect.height
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板 sentinel 注册回调(每个 v-for 项的 ref 调用一次,挂载时 el 非空、卸载时为 null)。
|
||||
* 调用方在 v-for item 上用 `:ref="(el) => registerSentinel(key, el as HTMLElement)"`。
|
||||
*/
|
||||
function registerSentinel(key: string, el: Element | null | undefined): void {
|
||||
const prev = itemMeta.get(key)
|
||||
if (el instanceof HTMLElement) {
|
||||
el.dataset.vscrollKey = key
|
||||
// 复用上次实测高度作为初始 inline 占位(再次挂载时先占位,内容挂载后 RO 再校准)。
|
||||
// 防御(P0):首次挂载(无 prev / RO 未回填)h<=0 时若不设占位,sentinel 首帧塌 0 高 →
|
||||
// 列表首次渲染时下条上浮重叠。此处用保守默认 40px 占位,RO 回调后用真实高度覆盖。
|
||||
const h = prev?.height ?? 0
|
||||
const placeholder = h > 0 ? h : 40
|
||||
el.style.minHeight = `${placeholder}px`
|
||||
itemMeta.set(key, { el, height: h })
|
||||
ensureObserver()
|
||||
io?.observe(el)
|
||||
ro?.observe(el)
|
||||
// 新挂载/初次进入的项默认视为应渲染(IO 回调会按真实相交态修正)
|
||||
if (!renderedKeys.value.has(key)) {
|
||||
const next = new Set(renderedKeys.value)
|
||||
next.add(key)
|
||||
renderedKeys.value = next
|
||||
}
|
||||
} else if (prev) {
|
||||
// 卸载:停止观测,保留 height 元数据供下次复用(itemMeta 不删)
|
||||
io?.unobserve(prev.el)
|
||||
ro?.unobserve(prev.el)
|
||||
// el 已被 Vue 移除,记录保留 height 即可
|
||||
itemMeta.set(key, { el: prev.el, height: prev.height })
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动容器 ref 就绪后调用(通常 onMounted),补建观察器并预渲染首批可见项。 */
|
||||
function setupOnMount(): void {
|
||||
ensureObserver()
|
||||
// 不主动全量 observe——registerSentinel 在模板挂载时已逐项 observe。
|
||||
}
|
||||
|
||||
/** 是否应渲染该项内容(IO 可见 或 pinned)。 */
|
||||
function shouldRender(key: string): boolean {
|
||||
// 🔍 诊断(P0 消息重叠):临时禁用虚拟滚动裁剪,所有消息内容恒渲染,
|
||||
// 隔离"历史消息打开全重叠"根因。若禁用后重叠消失→虚拟滚动时序根因;
|
||||
// 若仍重叠→CSS/markdown DOM 根因。定位后回退此改动。
|
||||
void key
|
||||
return true
|
||||
// return renderedKeys.value.has(key) || pinnedKeys.value.has(key)
|
||||
}
|
||||
|
||||
/** 更新 pinned 集合(调用方 watch 流式末条后传入新集合)。 */
|
||||
function setPinned(keys: Set<string>): void {
|
||||
pinnedKeys.value = keys
|
||||
// pinned 的 key 强制进渲染集(IO 不可见也渲染)
|
||||
if (keys.size > 0) {
|
||||
const next = new Set(renderedKeys.value)
|
||||
let changed = false
|
||||
for (const k of keys) if (!next.has(k)) { next.add(k); changed = true }
|
||||
if (changed) renderedKeys.value = next
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
io?.disconnect()
|
||||
ro?.disconnect()
|
||||
io = null
|
||||
ro = null
|
||||
itemMeta.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
renderedKeys,
|
||||
pinnedKeys,
|
||||
registerSentinel,
|
||||
setupOnMount,
|
||||
shouldRender,
|
||||
setPinned,
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ async function detachPanel() {
|
||||
const win = new WebviewWindow('ai-detached', {
|
||||
url,
|
||||
title: 'DevFlow AI',
|
||||
width: 520,
|
||||
width: 600, // U-260618: 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致
|
||||
height: 700,
|
||||
minWidth: 360,
|
||||
minHeight: 400,
|
||||
@@ -183,17 +183,56 @@ async function dockDetached() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */
|
||||
/** 吸附态 AI 窗口宽度/间隙常量(U-260618) */
|
||||
const AI_DOCK_WIDTH = 600
|
||||
const AI_DOCK_GAP = 4
|
||||
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
|
||||
let _isSyncing = false
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||
* U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度,
|
||||
* 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */
|
||||
async function syncToMain() {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(600, size.height))
|
||||
if (_isSyncing) return
|
||||
_isSyncing = true
|
||||
try {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const { currentMonitor } = await import('@tauri-apps/api/window')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
|
||||
const monitor = await currentMonitor()
|
||||
const screenWidth = monitor?.size.width ?? 0
|
||||
|
||||
// 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口)
|
||||
if (await mainWin.isMaximized()) {
|
||||
await mainWin.unmaximize()
|
||||
await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸
|
||||
}
|
||||
|
||||
let pos = await mainWin.outerPosition()
|
||||
let size = await mainWin.innerSize()
|
||||
|
||||
// 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕
|
||||
if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) {
|
||||
const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x
|
||||
if (newMainWidth >= 400) {
|
||||
await mainWin.setSize(new PhysicalSize(newMainWidth, size.height))
|
||||
await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定
|
||||
pos = await mainWin.outerPosition()
|
||||
size = await mainWin.innerSize()
|
||||
}
|
||||
}
|
||||
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height))
|
||||
} catch (e) {
|
||||
console.error('[AI] syncToMain 失败:', e)
|
||||
} finally {
|
||||
_isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用) */
|
||||
|
||||
@@ -9,18 +9,22 @@
|
||||
//! 各视图绑定 confirmState 的 visible/msg 并把按钮点击回 answerConfirm 即可。
|
||||
//!
|
||||
//! i18n:composable 在 setup 内调用,文案由调用方用各自 useI18n().t 翻译后传入 msg,
|
||||
//! 这里无需 i18n 依赖。dangerLabel 由 ConfirmDialog 组件侧兜底为 common.delete。
|
||||
//! 这里无需 i18n 依赖。dangerLabel 透传给 ConfirmDialog,空串时组件兜底为 common.confirm
|
||||
//! (语义中性,删除/清空等场景应显式传 common.delete / 「清空」等)。
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ConfirmState {
|
||||
visible: boolean
|
||||
msg: string
|
||||
/** 危险按钮文案;空串回退到 ConfirmDialog 组件兜底(common.confirm) */
|
||||
dangerLabel: string
|
||||
resolve: null | ((v: boolean) => void)
|
||||
}
|
||||
|
||||
/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消 */
|
||||
export type ConfirmFn = (msg: string) => Promise<boolean>
|
||||
/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消。
|
||||
* dangerLabel 可选:覆盖危险按钮文案(如「清空」),不传则回退组件兜底 common.confirm,保持旧行为。 */
|
||||
export type ConfirmFn = (msg: string, dangerLabel?: string) => Promise<boolean>
|
||||
|
||||
/** 关闭弹层并回传结果 */
|
||||
export type AnswerFn = (ok: boolean) => void
|
||||
@@ -29,15 +33,17 @@ export function useConfirm() {
|
||||
const confirmState = ref<ConfirmState>({
|
||||
visible: false,
|
||||
msg: '',
|
||||
dangerLabel: '',
|
||||
resolve: null,
|
||||
})
|
||||
|
||||
function confirmDialog(msg: string): Promise<boolean> {
|
||||
function confirmDialog(msg: string, dangerLabel = ''): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
// CR-260615-31:并发覆盖前一个 confirmDialog(连点删除/confirm 交错)时,
|
||||
// 前一个 Promise 永挂(resolve 被覆盖)→ 先 resolve?.(false) 打断前一个视为取消
|
||||
confirmState.value.resolve?.(false)
|
||||
confirmState.value.msg = msg
|
||||
confirmState.value.dangerLabel = dangerLabel
|
||||
confirmState.value.visible = true
|
||||
confirmState.value.resolve = resolve
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ export default {
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
earlier: 'Earlier',
|
||||
groupEmpty: 'None',
|
||||
|
||||
// ── Header buttons ──
|
||||
conversationList: 'Chat list',
|
||||
@@ -155,7 +156,10 @@ export default {
|
||||
clearContext: 'Clear context',
|
||||
compressContext: 'Compress context',
|
||||
// Confirm dialog (clear is semantically irreversible; history archived, not deleted)
|
||||
clearContextConfirm: 'Clear the current chat context? History is archived and preserved; new chats are not affected.',
|
||||
// Title "Clear context" folded into the lead sentence; stress irreversible + archived segment + AI no longer sees history
|
||||
clearContextConfirm: 'Clear context? This action is irreversible. The current chat context will be archived as a segment (the AI will no longer see these history messages). New chats are not affected.',
|
||||
// Danger button label for the confirm dialog (ConfirmDialog dangerLabel); falls back to common.confirm if omitted
|
||||
clearContextDangerLabel: 'Clear',
|
||||
// Collapsed separator (consecutive same-status merged into one bar; {n}=merged message count)
|
||||
contextArchived: 'Archived {n} messages',
|
||||
compressed: 'Context compressed',
|
||||
|
||||
@@ -11,12 +11,17 @@ export default {
|
||||
// UX-260616-01: tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
|
||||
executionFailed: 'Failed',
|
||||
executionFailedHint: 'Tool execution failed, see details below',
|
||||
// Singular expand/collapse (ToolCard single-card content, command output toggle; expandAll/collapseAll are for ToolCardList global multi-group)
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
expandAll: 'Expand all',
|
||||
collapseAll: 'Collapse all',
|
||||
written: 'written',
|
||||
items: 'items',
|
||||
lines: 'lines',
|
||||
// UX-260618-08/11: truncation hints (read_file paged truncation / list_directory 1000-entry cap)
|
||||
linesTruncated: '{shown}/{total} lines, truncated',
|
||||
dirTruncated: 'Truncated, first {n} items only',
|
||||
|
||||
readPrefix: 'Read',
|
||||
readFallback: 'Read File',
|
||||
@@ -39,6 +44,9 @@ export default {
|
||||
taskCount: '{n} tasks',
|
||||
projectCount: '{n} projects',
|
||||
ideaCount: '{n} ideas',
|
||||
// UX-260618-10/12: list_trash collapsed header count + paged has_more hint (backend returns {items,total,has_more} object)
|
||||
trashCount: '{n} in trash',
|
||||
listHasMore: '({n} shown, more available)',
|
||||
createdWithName: 'Created: {name}',
|
||||
created: 'Created',
|
||||
updatedField: 'Updated {field}',
|
||||
@@ -50,22 +58,38 @@ export default {
|
||||
purged: 'Purged',
|
||||
purgeIneffective: 'Purge ineffective',
|
||||
workflowHint: 'Run it on the workflow page',
|
||||
// UX-260618-05: run_workflow tool_result shows execution_id (workflowHint misleading — workflow already triggered; old key kept for other refs)
|
||||
workflowTriggered: 'Task {id} triggered workflow (advancing to {status}, execution {exec})',
|
||||
workflowTriggeredShort: 'Execution {exec}',
|
||||
|
||||
// UX-260616-04: formatToolResult covers all tools with human-readable summaries (missing keys)
|
||||
updatedTaskField: 'Task {id} updated field {field}',
|
||||
advancedTask: 'Task {id} advanced to {status}',
|
||||
// UX-260618-09: advance_task return-round accumulation (shown only when >0)
|
||||
advancedTaskWithRounds: 'Task {id} advanced to {status} (returned {n} times)',
|
||||
commandOk: 'Command succeeded ({ms} ms)',
|
||||
commandFailed: 'Command failed (exit code {code})',
|
||||
commandOutputLines: '{n} lines of output',
|
||||
patchedFile: 'Patched {path} ({diff} bytes)',
|
||||
// UX-260618-06: patch_file multi-match hint + size_diff unit
|
||||
patchedMatches: 'matched {n} places',
|
||||
bytesUnit: 'bytes',
|
||||
patchedNoChange: '{path} no change',
|
||||
appendedTo: 'Appended {bytes} to {path}',
|
||||
renamedFile: 'Renamed {from} → {to}',
|
||||
// UX-260618-12: rename_file bytes/cross-volume + write_file overwrite/encoding hints
|
||||
renamedWithBytes: 'Renamed {from} → {to} ({bytes})',
|
||||
renamedCrossVolume: 'Moved {from} → {to} (cross-volume, {bytes})',
|
||||
writeOverwrite: 'overwrote {old} → {neww}',
|
||||
encodingLabel: 'encoding: {enc}',
|
||||
foundN: 'Found {n}',
|
||||
boundDir: 'Bound directory "{path}" stack: {stack}',
|
||||
deleteFilePermanent: 'Permanently deleted {path}',
|
||||
deleteFileSoft: 'Soft-deleted {path} (backed up)',
|
||||
// UX-260618-07: soft-delete adds backup_path (trash filename so user knows where to recover from)
|
||||
deleteFileSoft: 'Soft-deleted {path} (backed up, recover from: {backup_path})',
|
||||
fileInfoSize: '{path} {size}',
|
||||
// UX-260618-12: file_info modified time (epoch_ms → client toLocaleString)
|
||||
fileInfoModified: ' · modified {time}',
|
||||
fileInfoLines: ' · {n} lines',
|
||||
fileInfoBinary: ' · binary',
|
||||
fileInfoDir: ' · directory',
|
||||
|
||||
46
src/i18n/en/auditLog.ts
Normal file
46
src/i18n/en/auditLog.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export default {
|
||||
auditLog: {
|
||||
title: 'Approval History',
|
||||
refresh: 'Refresh',
|
||||
desc: 'AI tool call audit records (newest first). Arguments and results show summaries only; full data persists in the local database.',
|
||||
loading: 'Loading…',
|
||||
empty: 'No audit records',
|
||||
|
||||
col: {
|
||||
time: 'Requested',
|
||||
tool: 'Tool',
|
||||
risk: 'Risk',
|
||||
status: 'Status',
|
||||
decided: 'Decided by',
|
||||
args: 'Args summary',
|
||||
result: 'Result summary',
|
||||
},
|
||||
|
||||
pager: {
|
||||
prev: 'Prev',
|
||||
next: 'Next',
|
||||
page: 'Page {n}',
|
||||
last: '(last)',
|
||||
},
|
||||
|
||||
risk: {
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
},
|
||||
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
executing: 'Executing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
},
|
||||
|
||||
decided: {
|
||||
human: 'Human',
|
||||
auto: 'Auto',
|
||||
},
|
||||
},
|
||||
}
|
||||
7
src/i18n/en/error.ts
Normal file
7
src/i18n/en/error.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
error: {
|
||||
title: 'Render Error',
|
||||
desc: 'This section failed to render due to an internal error. Try reloading.',
|
||||
retry: 'Retry',
|
||||
},
|
||||
}
|
||||
@@ -51,11 +51,9 @@ export default {
|
||||
fetchFailedNotFound: 'Endpoint not found, this vendor may not support model listing: {msg}',
|
||||
toastModelEditLocalOnly: 'Enabled / weight are local edits, overwritten on next fetch',
|
||||
|
||||
// Model trait tags (4 dimensions + probe source)
|
||||
// Model trait tags (modality + capability + probe source; cost/intel 去除见 UX-260618-04)
|
||||
tagModality: { text: 'Text', vision: 'Vision', audio: 'Audio', video: 'Video' },
|
||||
tagCapability: { tool_use: 'Tool use', embedding: 'Embedding', code_gen: 'Code' },
|
||||
tagCost: { free: 'Free', low: 'Low', medium: 'Medium', high: 'High' },
|
||||
tagIntel: { lite: 'Lite', standard: 'Standard', plus: 'Plus', ultra: 'Ultra' },
|
||||
tagSource: { user_set: 'Manual', preset_table: 'Preset', heuristic: 'Heuristic', default: 'Default' },
|
||||
|
||||
// ===== Connection panel =====
|
||||
|
||||
@@ -27,6 +27,7 @@ export default {
|
||||
group: {
|
||||
taskCount: '{n} tasks',
|
||||
unknownProject: 'Unknown Project',
|
||||
empty: 'No tasks yet',
|
||||
},
|
||||
|
||||
// New task modal
|
||||
|
||||
@@ -20,6 +20,7 @@ export default {
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
earlier: '更早',
|
||||
groupEmpty: '暂无',
|
||||
|
||||
// ── Header 按钮 ──
|
||||
conversationList: '对话列表',
|
||||
@@ -156,7 +157,10 @@ export default {
|
||||
clearContext: '清空上下文',
|
||||
compressContext: '压缩上下文',
|
||||
// 二次确认(清空不可逆语义,防误触;历史归档保留不删)
|
||||
clearContextConfirm: '确定清空当前对话上下文?历史消息归档保留,不影响新对话',
|
||||
// 标题「清空上下文」融入正文首句;强调不可逆 + 归档分段 + AI 不再看到历史
|
||||
clearContextConfirm: '清空上下文?此操作不可逆,当前对话上下文将被归档为分段(AI 将不再看到这些历史消息)。新对话不受影响。',
|
||||
// 二次确认弹窗的危险按钮文案(ConfirmDialog dangerLabel);不传则回退 common.confirm
|
||||
clearContextDangerLabel: '清空',
|
||||
// 折叠分隔条(连续同 status 合并一条;{n}=合并的消息条数)
|
||||
contextArchived: '已归档 {n} 条消息',
|
||||
compressed: '已压缩上下文',
|
||||
|
||||
@@ -13,10 +13,14 @@ export default {
|
||||
executionFailedHint: '工具执行失败,详情见下方',
|
||||
collapse: '收起',
|
||||
expandAll: '展开全部',
|
||||
expand: '展开',
|
||||
collapseAll: '全部收起',
|
||||
written: '已写入',
|
||||
items: '项',
|
||||
lines: '行',
|
||||
// UX-260618-08/11:截断提示(read_file 分页截断 / list_directory 1000 上限截断)
|
||||
linesTruncated: '{shown}/{total} 行,已截断',
|
||||
dirTruncated: '已截断,仅前 {n} 项',
|
||||
|
||||
// 工具显示名(含路径摘要前缀,fallback 兜底)
|
||||
readPrefix: '读取',
|
||||
@@ -41,6 +45,9 @@ export default {
|
||||
taskCount: '{n} 项任务',
|
||||
projectCount: '{n} 个项目',
|
||||
ideaCount: '{n} 条灵感',
|
||||
// UX-260618-10/12:list_trash 折叠态计数 + 分页 has_more 提示(后端返 {items,total,has_more} 对象)
|
||||
trashCount: '回收站 {n} 项',
|
||||
listHasMore: '(已显示 {n} 项,还有更多)',
|
||||
createdWithName: '已创建:{name}',
|
||||
created: '已创建',
|
||||
updatedField: '已更新 {field}',
|
||||
@@ -52,22 +59,38 @@ export default {
|
||||
purged: '已彻底删除',
|
||||
purgeIneffective: '清除未生效',
|
||||
workflowHint: '请到工作流页面运行',
|
||||
// UX-260618-05:run_workflow tool_result 显 execution_id(workflowHint 误导——工作流已触发;旧 key 保留防他处引用)
|
||||
workflowTriggered: '任务 {id} 已触发工作流(推进至 {status},执行实例 {exec})',
|
||||
workflowTriggeredShort: '执行实例 {exec}',
|
||||
|
||||
// UX-260616-04:formatToolResult 覆盖全部工具产出人类可读摘要(补缺失 key)
|
||||
updatedTaskField: '任务 {id} 已更新字段 {field}',
|
||||
advancedTask: '任务 {id} 已推进至 {status}',
|
||||
// UX-260618-09:advance_task 退回累加轮数(>0 才显)
|
||||
advancedTaskWithRounds: '任务 {id} 已推进至 {status}(已退回 {n} 次)',
|
||||
commandOk: '命令成功({ms} 毫秒)',
|
||||
commandFailed: '命令失败(退出码 {code})',
|
||||
commandOutputLines: '{n} 行输出',
|
||||
patchedFile: '已修改 {path}(增减 {diff} 字节)',
|
||||
// UX-260618-06:patch_file 多匹配提示 + size_diff 单位
|
||||
patchedMatches: '匹配 {n} 处',
|
||||
bytesUnit: '字节',
|
||||
patchedNoChange: '{path} 无更改',
|
||||
appendedTo: '已向 {path} 追加 {bytes}',
|
||||
renamedFile: '已重命名 {from} → {to}',
|
||||
// UX-260618-12:rename_file 字节/跨卷 + write_file 覆盖/编码提示
|
||||
renamedWithBytes: '已重命名 {from} → {to}({bytes})',
|
||||
renamedCrossVolume: '已移动 {from} → {to}(跨卷,{bytes})',
|
||||
writeOverwrite: '覆盖 {old} → {neww}',
|
||||
encodingLabel: '编码:{enc}',
|
||||
foundN: '找到 {n} 项',
|
||||
boundDir: '已绑定目录「{path}」技术栈:{stack}',
|
||||
deleteFilePermanent: '已硬删除 {path}',
|
||||
deleteFileSoft: '已软删除 {path}(已备份)',
|
||||
// UX-260618-07:软删补 backup_path(回收站文件名,用户知从哪恢复)
|
||||
deleteFileSoft: '已软删除 {path}(已备份,可从 {backup_path} 恢复)',
|
||||
fileInfoSize: '{path} {size}',
|
||||
// UX-260618-12:file_info 修改时间(epoch_ms → 客户端 toLocaleString)
|
||||
fileInfoModified: ' · 修改于 {time}',
|
||||
fileInfoLines: ' · {n} 行',
|
||||
fileInfoBinary: ' · 二进制',
|
||||
fileInfoDir: ' · 目录',
|
||||
|
||||
46
src/i18n/zh-CN/auditLog.ts
Normal file
46
src/i18n/zh-CN/auditLog.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export default {
|
||||
auditLog: {
|
||||
title: '审批历史',
|
||||
refresh: '刷新',
|
||||
desc: 'AI 工具调用审计记录(按请求时间倒序)。参数与结果仅展示摘要,完整数据留存本地数据库。',
|
||||
loading: '加载中…',
|
||||
empty: '暂无审计记录',
|
||||
|
||||
col: {
|
||||
time: '请求时间',
|
||||
tool: '工具',
|
||||
risk: '风险',
|
||||
status: '状态',
|
||||
decided: '决策者',
|
||||
args: '参数摘要',
|
||||
result: '结果摘要',
|
||||
},
|
||||
|
||||
pager: {
|
||||
prev: '上一页',
|
||||
next: '下一页',
|
||||
page: '第 {n} 页',
|
||||
last: '(末页)',
|
||||
},
|
||||
|
||||
risk: {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
},
|
||||
|
||||
status: {
|
||||
pending: '待审批',
|
||||
approved: '已批准',
|
||||
rejected: '已拒绝',
|
||||
executing: '执行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
},
|
||||
|
||||
decided: {
|
||||
human: '人工',
|
||||
auto: '自动',
|
||||
},
|
||||
},
|
||||
}
|
||||
7
src/i18n/zh-CN/error.ts
Normal file
7
src/i18n/zh-CN/error.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
error: {
|
||||
title: '页面渲染异常',
|
||||
desc: '当前区域因内部错误无法显示,可尝试重新加载。',
|
||||
retry: '重试',
|
||||
},
|
||||
}
|
||||
@@ -51,11 +51,9 @@ export default {
|
||||
fetchFailedNotFound: '端点不存在,该厂商可能不支持模型列表拉取:{msg}',
|
||||
toastModelEditLocalOnly: '已启用 / 权重为本地编辑,下次拉取时覆盖',
|
||||
|
||||
// 模型特征标签(4 维度 + 探测来源)
|
||||
// 模型特征标签(模态 + 能力 + 探测来源;cost/intel 去除见 UX-260618-04)
|
||||
tagModality: { text: '文本', vision: '视觉', audio: '音频', video: '视频' },
|
||||
tagCapability: { tool_use: '工具调用', embedding: '向量', code_gen: '代码' },
|
||||
tagCost: { free: '免费', low: '低价', medium: '中价', high: '高价' },
|
||||
tagIntel: { lite: '轻量', standard: '标准', plus: '增强', ultra: '旗舰' },
|
||||
tagSource: { user_set: '手动', preset_table: '预设', heuristic: '推断', default: '默认' },
|
||||
|
||||
// ===== 连接管理面板 =====
|
||||
|
||||
@@ -27,6 +27,7 @@ export default {
|
||||
group: {
|
||||
taskCount: '{n} 个任务',
|
||||
unknownProject: '未知项目',
|
||||
empty: '暂无任务',
|
||||
},
|
||||
|
||||
// 新建任务模态
|
||||
|
||||
11
src/main.ts
11
src/main.ts
@@ -5,4 +5,13 @@ import i18n from "./i18n";
|
||||
import "./styles/global.css";
|
||||
import "./styles/ai-md.css";
|
||||
|
||||
createApp(App).use(router).use(i18n).mount("#app");
|
||||
const app = createApp(App);
|
||||
|
||||
// 全局错误兜底:捕获 Vue runtime 未处理错误(组件渲染/生命周期/事件回调抛错),
|
||||
// 仅记录不中断,防止整页白屏。组件级降级 UI 由 ErrorBoundary.vue 负责。
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
const tag = instance?.$options?.name || instance?.$options?.__name || "unknown";
|
||||
console.error(`[Vue error] <${tag}> ${info}:`, err);
|
||||
};
|
||||
|
||||
app.use(router).use(i18n).mount("#app");
|
||||
|
||||
@@ -89,6 +89,8 @@ export const state = reactive({
|
||||
agentRound: 0,
|
||||
// 归档分组折叠态(默认折叠)
|
||||
archivedCollapsed: true,
|
||||
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)
|
||||
foldedGroups: { today: false, yesterday: false, earlier: true } as Record<string, boolean>,
|
||||
// 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。
|
||||
// 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。
|
||||
searchQuery: '',
|
||||
|
||||
@@ -26,9 +26,13 @@ const state = reactive({
|
||||
// ── 异步竞态保护(请求序号,类似 ai.ts messages 的 switch token 模式) ──
|
||||
// 快速连点(切换筛选/连续检索/重连)时,旧响应晚到会覆盖新响应。序号自增,
|
||||
// 请求发起时捕获序号,响应回时比对:非最新请求的响应直接丢弃,只取最新。
|
||||
// items 序号覆盖 loadList/loadCandidates/search;candidates 与 items 共用同一队列
|
||||
// (loadList/search 写 items,loadCandidates 写 candidates),故 items 序号也兜底 candidates。
|
||||
let _itemsReqSeq = 0
|
||||
// 拆两套序号:loadList/search 都写 state.items 共用 _itemsSeq;loadCandidates 写
|
||||
// state.candidates 用独立 _candidatesSeq。各自防竞态,互不丢弃。
|
||||
// (P1 修复:原先三者共用 _itemsReqSeq,onMounted 并发 loadList+loadCandidates 会
|
||||
// 让后发起的 loadCandidates ++ 覆盖 loadList 的 seq,导致 loadList 响应被误判为
|
||||
// 旧响应丢弃,首次进入列表必空。)
|
||||
let _itemsSeq = 0
|
||||
let _candidatesSeq = 0
|
||||
let _configReqSeq = 0
|
||||
|
||||
// 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐)
|
||||
@@ -59,48 +63,48 @@ export function useKnowledgeStore() {
|
||||
// 后端 knowledge_list 默认收窄为 published,无需前端传 status。
|
||||
// 显式 status 仍透传(如 status='archived' 查归档)。
|
||||
async function loadList(status?: string) {
|
||||
const seq = ++_itemsReqSeq
|
||||
const seq = ++_itemsSeq
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
const result = await knowledgeApi.list(status ?? null)
|
||||
if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新
|
||||
if (seq !== _itemsSeq) return // 旧响应丢弃,只取最新
|
||||
state.items = result
|
||||
} catch (e: any) {
|
||||
if (seq !== _itemsReqSeq) return // 旧请求的失败不污染当前视图
|
||||
if (seq !== _itemsSeq) return // 旧请求的失败不污染当前视图
|
||||
state.error = e?.toString() ?? t('knowledge.err.loadFailed')
|
||||
} finally {
|
||||
if (seq === _itemsReqSeq) state.loading = false
|
||||
if (seq === _itemsSeq) state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载待处理收件箱(candidate + pending_review,F-260616-02 决策 a) ──
|
||||
// 后端 knowledge_list_candidates 已收两种待处理状态,前端调用不变。
|
||||
async function loadCandidates() {
|
||||
const seq = ++_itemsReqSeq
|
||||
const seq = ++_candidatesSeq
|
||||
try {
|
||||
const result = await knowledgeApi.listCandidates()
|
||||
if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新
|
||||
if (seq !== _candidatesSeq) return // 旧响应丢弃,只取最新
|
||||
state.candidates = result
|
||||
} catch (e: any) {
|
||||
if (seq !== _itemsReqSeq) return
|
||||
if (seq !== _candidatesSeq) return
|
||||
state.error = e?.toString() ?? t('knowledge.err.loadInboxFailed')
|
||||
}
|
||||
}
|
||||
|
||||
async function search(query: string, kind?: string) {
|
||||
const seq = ++_itemsReqSeq
|
||||
const seq = ++_itemsSeq
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
const result = await knowledgeApi.search({ query, kind })
|
||||
if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新
|
||||
if (seq !== _itemsSeq) return // 旧响应丢弃,只取最新
|
||||
state.items = result
|
||||
} catch (e: any) {
|
||||
if (seq !== _itemsReqSeq) return
|
||||
if (seq !== _itemsSeq) return
|
||||
state.error = e?.toString() ?? t('knowledge.err.searchFailed')
|
||||
} finally {
|
||||
if (seq === _itemsReqSeq) state.loading = false
|
||||
if (seq === _itemsSeq) state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,16 @@
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
margin: 8px 0;
|
||||
}
|
||||
/* B-260618-06:移除 display:block(破坏 table 布局上下文致 tr/td 塌堆叠丢列对齐,用户截图
|
||||
反馈测试报告表格错乱根因)+ 移除 overflow-x:auto(display:table 下浏览器忽略无效)。
|
||||
table 恢复默认 display:table 列对齐恢复;width:100%→max-width:100% 允许窄表格按内容
|
||||
自适应不强制拉满。宽表格横向滚动交由各页面容器承载(AiChat 气泡 .ai-msg-bubble--ai.ai-md
|
||||
加 overflow-x:auto,见 AiChat.vue)。原 UX-260617-21 display:block 方案治标破坏本体。 */
|
||||
.ai-md table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
.ai-md th, .ai-md td {
|
||||
padding: 4px 8px;
|
||||
|
||||
34
src/utils/markdown.ts
Normal file
34
src/utils/markdown.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 剥离 Markdown 语法字符,返回纯文本。
|
||||
*
|
||||
* 用于列表/卡片摘要等纯文本预览场景(详情页应 v-html 富文本渲染,不用此函数)。
|
||||
* 仅去除粗体、斜体、删除线、标题、行内代码、代码块围栏、列表、引用、
|
||||
* 链接、图片等 Markdown 语法标记,保留文字内容。
|
||||
*
|
||||
* 动机(UX-260618-13):列表摘要用纯文本插值显示 AI 生成内容,
|
||||
* 数据源含 Markdown 语法字符会裸字符外露,列表空间小且配合截断,
|
||||
* 剥离比富文本渲染更合适。
|
||||
*/
|
||||
|
||||
/** 剥离 Markdown 语法,返回纯文本;空输入返回空串 */
|
||||
export function stripMd(text: string | null | undefined): string {
|
||||
if (!text) return ''
|
||||
return text
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 alt
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接文本
|
||||
.replace(/```[a-zA-Z]*\n?/g, '') // 代码块围栏开
|
||||
.replace(/```/g, '') // 代码块围栏闭
|
||||
.replace(/`([^`]+)`/g, '$1') // 行内代码
|
||||
.replace(/^\s{0,3}>\s?/gm, '') // 引用
|
||||
.replace(/^\s{0,3}[-*+]\s+/gm, '') // 无序列表
|
||||
.replace(/^\s{0,3}\d+\.\s+/gm, '') // 有序列表
|
||||
.replace(/^#{1,6}\s+/gm, '') // 标题
|
||||
.replace(/^\s*[-=]{3,}\s*$/gm, '') // 水平线
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1') // 粗体
|
||||
.replace(/__([^_]+)__/g, '$1') // 粗体下划线
|
||||
.replace(/\*([^*]+)\*/g, '$1') // 斜体
|
||||
.replace(/_([^_]+)_/g, '$1') // 斜体下划线
|
||||
.replace(/~~([^~]+)~~/g, '$1') // 删除线
|
||||
.replace(/\s+/g, ' ') // 折叠连续空白
|
||||
.trim()
|
||||
}
|
||||
@@ -1,31 +1,31 @@
|
||||
<template>
|
||||
<div class="audit-log">
|
||||
<header class="page-header">
|
||||
<h1>审批历史</h1>
|
||||
<h1>{{ t('auditLog.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="reload">刷新</button>
|
||||
<button class="btn btn-ghost" @click="reload">{{ t('auditLog.refresh') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="page-desc">AI 工具调用审计记录(按请求时间倒序)。参数与结果仅展示摘要,完整数据留存本地数据库。</p>
|
||||
<p class="page-desc">{{ t('auditLog.desc') }}</p>
|
||||
|
||||
<!-- 状态条 -->
|
||||
<div v-if="loading" class="empty-state">加载中…</div>
|
||||
<div v-if="loading" class="empty-state">{{ t('auditLog.loading') }}</div>
|
||||
<div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div>
|
||||
<div v-else-if="records.length === 0" class="empty-state">暂无审计记录</div>
|
||||
<div v-else-if="records.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="audit-table-wrap">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">请求时间</th>
|
||||
<th class="col-tool">工具</th>
|
||||
<th class="col-risk">风险</th>
|
||||
<th class="col-status">状态</th>
|
||||
<th class="col-decided">决策者</th>
|
||||
<th class="col-args">参数摘要</th>
|
||||
<th class="col-result">结果摘要</th>
|
||||
<th class="col-time">{{ t('auditLog.col.time') }}</th>
|
||||
<th class="col-tool">{{ t('auditLog.col.tool') }}</th>
|
||||
<th class="col-risk">{{ t('auditLog.col.risk') }}</th>
|
||||
<th class="col-status">{{ t('auditLog.col.status') }}</th>
|
||||
<th class="col-decided">{{ t('auditLog.col.decided') }}</th>
|
||||
<th class="col-args">{{ t('auditLog.col.args') }}</th>
|
||||
<th class="col-result">{{ t('auditLog.col.result') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -54,18 +54,21 @@
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="!loading && !errorMsg" class="pager">
|
||||
<button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">上一页</button>
|
||||
<span class="pager-info">第 {{ page }} 页{{ hasMore ? '' : '(末页)' }}</span>
|
||||
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">下一页</button>
|
||||
<button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">{{ t('auditLog.pager.prev') }}</button>
|
||||
<span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}</span>
|
||||
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelativeZh, formatDate } from '@/utils/time'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 本地 interface(不进 api/types.ts,避撞 B-42 agent)
|
||||
// 字段与后端 ToolExecutionDto(audit.rs) 一一对应
|
||||
interface ToolExecutionRecord {
|
||||
@@ -128,21 +131,15 @@ function nextPage() {
|
||||
}
|
||||
|
||||
// ── 标签 / 样式映射 ──
|
||||
// 文本走 i18n(auditLog.risk/status/decided);class 映射不国际化
|
||||
function riskLabel(r: string): string {
|
||||
return ({ low: '低', medium: '中', high: '高' } as Record<string, string>)[r] ?? r
|
||||
return t(`auditLog.risk.${r}`)
|
||||
}
|
||||
function riskClass(r: string): string {
|
||||
return ({ low: 'risk-low', medium: 'risk-medium', high: 'risk-high' } as Record<string, string>)[r] ?? 'risk-low'
|
||||
}
|
||||
function statusLabel(s: string): string {
|
||||
return ({
|
||||
pending: '待审批',
|
||||
approved: '已批准',
|
||||
rejected: '已拒绝',
|
||||
executing: '执行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
} as Record<string, string>)[s] ?? s
|
||||
return t(`auditLog.status.${s}`)
|
||||
}
|
||||
function statusClass(s: string): string {
|
||||
return ({
|
||||
@@ -155,7 +152,7 @@ function statusClass(s: string): string {
|
||||
} as Record<string, string>)[s] ?? 'status-pending'
|
||||
}
|
||||
function decidedLabel(d: string): string {
|
||||
return d === 'human' ? '人工' : d === 'auto' ? '自动' : d
|
||||
return t(`auditLog.decided.${d}`)
|
||||
}
|
||||
function decidedClass(d: string): string {
|
||||
return d === 'human' ? 'decided-human' : 'decided-auto'
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<span class="idea-title">{{ idea.title }}</span>
|
||||
<span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span>
|
||||
</div>
|
||||
<p class="idea-desc-preview">{{ idea.description?.slice(0, 60) ?? '' }}{{ idea.description && idea.description.length > 60 ? '...' : '' }}</p>
|
||||
<p class="idea-desc-preview">{{ stripMd(idea.description).slice(0, 60) }}{{ stripMd(idea.description).length > 60 ? '...' : '' }}</p>
|
||||
<div class="idea-card-footer">
|
||||
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
|
||||
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
||||
@@ -145,8 +145,8 @@
|
||||
<!-- 标签 -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.tagsTitle') }}</h3>
|
||||
<div class="tag-list" v-if="parseTags(currentIdea).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(currentIdea)" :key="tag">{{ tag }}</span>
|
||||
<div class="tag-list" v-if="parseTags(currentIdea.tags).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(currentIdea.tags)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noTags') }}</div>
|
||||
</div>
|
||||
@@ -211,7 +211,9 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { parseTags } from '../stores/knowledge'
|
||||
import { formatDate } from '../utils/time'
|
||||
import { stripMd } from '../utils/markdown'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useRendered } from '../composables/useMarkdown'
|
||||
@@ -274,7 +276,7 @@ const filteredIdeas = computed(() => {
|
||||
ideas = ideas.filter(i =>
|
||||
i.title.toLowerCase().includes(query) ||
|
||||
(i.description && i.description.toLowerCase().includes(query)) ||
|
||||
(i.tags && JSON.parse(i.tags || '[]').some((tag: string) => tag.toLowerCase().includes(query)))
|
||||
parseTags(i.tags).some((tag: string) => tag.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,16 +298,6 @@ const currentStatus = computed(() => {
|
||||
return currentIdea.value?.status || 'draft'
|
||||
})
|
||||
|
||||
function parseTags(idea: IdeaRecord): string[] {
|
||||
if (!idea.tags) return []
|
||||
try {
|
||||
const parsed = JSON.parse(idea.tags)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface ScoreDimension { name: string; score: number }
|
||||
|
||||
function parseScores(idea: IdeaRecord): ScoreDimension[] {
|
||||
@@ -734,6 +726,7 @@ watch(() => route.params.id, (id) => {
|
||||
|
||||
.net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); }
|
||||
.net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); }
|
||||
.net-sentiment.neutral { background: var(--df-bg-raised); color: var(--df-text-secondary); }
|
||||
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<span class="kn-card-title">{{ item.title }}</span>
|
||||
<span class="kn-card-status" :class="'st-' + item.status">{{ statusLabel(item.status) }}</span>
|
||||
</div>
|
||||
<div class="kn-card-desc">{{ item.content }}</div>
|
||||
<div class="kn-card-desc">{{ stripMd(item.content) }}</div>
|
||||
<div class="kn-card-meta">
|
||||
<span class="meta-kind">{{ kindLabel(item.kind) }}</span>
|
||||
<span v-if="item.confidence" :class="'conf-' + item.confidence">{{ confidenceLabel(item.confidence) }}</span>
|
||||
@@ -238,10 +238,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '@/stores/knowledge'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
import { stripMd } from '@/utils/markdown'
|
||||
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -277,6 +278,15 @@ function onSearchInput() {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 组件卸载清搜索 debounce timer:防卸载后 300ms 内 timer 触发,向单例 store 写入
|
||||
// 已离开视图的搜索结果/列表(污染下次进入时的列表态)。对齐 Settings.vue toast timer 清理模式。
|
||||
onUnmounted(() => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function switchTopTab(tab: 'library' | 'inbox') {
|
||||
topTab.value = tab
|
||||
selectedId.value = null
|
||||
@@ -422,7 +432,9 @@ function parseContext(e: KnowledgeEventRecord): Record<string, any> {
|
||||
}
|
||||
|
||||
function refConvTitle(e: KnowledgeEventRecord): string {
|
||||
return parseContext(e).conv_title || parseContext(e).conv_id || ''
|
||||
// 解析一次复用:原两次 parseContext 调用会重复 JSON.parse,引用列表项越多浪费越大
|
||||
const ctx = parseContext(e)
|
||||
return ctx.conv_title || ctx.conv_id || ''
|
||||
}
|
||||
function refQuery(e: KnowledgeEventRecord): string {
|
||||
return parseContext(e).query || ''
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card-desc">{{ project.description }}</p>
|
||||
<p class="card-desc">{{ stripMd(project.description) }}</p>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="card-footer">
|
||||
@@ -180,7 +180,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
@@ -188,6 +188,7 @@ import { useProjectStore } from '@/stores/project'
|
||||
import { projectApi } from '@/api'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { parseStack } from '@/utils/project'
|
||||
import { stripMd } from '@/utils/markdown'
|
||||
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -302,6 +303,15 @@ onMounted(() => {
|
||||
store.loadProjects()
|
||||
})
|
||||
|
||||
// 卸载清 toast timer:防组件已离开视图后 4s 内 timer 触发向已销毁组件写状态。
|
||||
// 对齐 Settings.vue 的 _toastTimer 清理模式(Knowledge.vue searchTimer 同模式)。
|
||||
onUnmounted(() => {
|
||||
if (_toastTimer) {
|
||||
clearTimeout(_toastTimer)
|
||||
_toastTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// ── 导入历史项目(F-260614-06 scan 第二步) ──
|
||||
const showImportModal = ref(false)
|
||||
const importRootPath = ref('')
|
||||
|
||||
@@ -194,6 +194,8 @@ const wfTotalNodes = ref(0)
|
||||
const wfDoneCount = ref(0)
|
||||
const wfRunningNode = ref<string>('')
|
||||
const wfResult = ref<'completed' | 'failed' | null>(null)
|
||||
// SW-260618-21: 终态提示 timer 引用,卸载时清理防写已销毁 ref(对齐 Projects.vue _toastTimer 模式)
|
||||
let _wfResultTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const wfProgressHint = computed(() => wfResult.value !== null)
|
||||
const wfCompletedHint = computed(() => wfResult.value === 'completed')
|
||||
@@ -408,8 +410,10 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
|
||||
wfRunningNode.value = ''
|
||||
wfResult.value = 'completed'
|
||||
// 终态提示保留 3s 后清空(任务本体由 df-data-changed 刷新,提示不影响数据)
|
||||
setTimeout(() => {
|
||||
if (_wfResultTimer) clearTimeout(_wfResultTimer)
|
||||
_wfResultTimer = setTimeout(() => {
|
||||
if (wfResult.value === 'completed') wfResult.value = null
|
||||
_wfResultTimer = null
|
||||
}, 3000)
|
||||
break
|
||||
}
|
||||
@@ -419,8 +423,10 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
|
||||
wfAdvancing.value = false
|
||||
wfRunningNode.value = ''
|
||||
wfResult.value = 'failed'
|
||||
setTimeout(() => {
|
||||
if (_wfResultTimer) clearTimeout(_wfResultTimer)
|
||||
_wfResultTimer = setTimeout(() => {
|
||||
if (wfResult.value === 'failed') wfResult.value = null
|
||||
_wfResultTimer = null
|
||||
}, 3000)
|
||||
}
|
||||
// node_failed: 单节点失败,工作流后续会发 workflow_failed,此处不提前改终态
|
||||
@@ -492,6 +498,8 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
|
||||
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
|
||||
// SW-260618-21: 清终态提示 timer 防卸载后写已销毁 ref
|
||||
if (_wfResultTimer) { clearTimeout(_wfResultTimer); _wfResultTimer = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
:class="{ active: activeStatus === s.key }"
|
||||
@click="activeStatus = s.key"
|
||||
>
|
||||
{{ s.icon }} {{ s.label }}
|
||||
<span class="filter-btn-icon">{{ s.icon }}</span>
|
||||
<span class="filter-btn-label">{{ s.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +42,7 @@
|
||||
<!-- Y-2: loading/error/empty 兜底(参考 Projects.vue 空态写法) -->
|
||||
<div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div>
|
||||
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
|
||||
<div v-else-if="filteredGroups.length === 0" class="empty-state">暂无任务</div>
|
||||
<div v-else-if="filteredGroups.length === 0" class="empty-state">{{ $t('tasks.group.empty') }}</div>
|
||||
<template v-else>
|
||||
<section
|
||||
v-for="group in filteredGroups"
|
||||
@@ -287,6 +288,7 @@ onMounted(async () => {
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* 窄屏按钮可换行,避免 nowrap 溢出 */
|
||||
gap: 6px;
|
||||
}
|
||||
.filter-label {
|
||||
@@ -295,6 +297,9 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
@@ -304,12 +309,22 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-btn-icon { line-height: 1; } /* 图标基线对齐,避免行高跳动 */
|
||||
.filter-btn-label { white-space: nowrap; } /* 文字不内断行,换行只发生在按钮间 */
|
||||
.filter-btn:hover { background: rgba(108, 99, 255, 0.06); color: var(--df-text); }
|
||||
.filter-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
/* 窄屏(手机宽):按钮内图标与文字改纵向排布,避免横向挤压致图标/文字在空格处错位断行 */
|
||||
@media (max-width: 480px) {
|
||||
.filter-btn {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 任务分组 ===== */
|
||||
.task-groups {
|
||||
|
||||
Reference in New Issue
Block a user