优化: AI Chat 渲染与无障碍 + 文件查看 + 同步 IO 收口
- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰) - 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch - TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重 - 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible - UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认) - 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化 - 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs - 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传 - crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
<button
|
||||
class="approval-overlay-close"
|
||||
:title="$t('common.close')"
|
||||
:aria-label="$t('common.close')"
|
||||
@click.stop="onDismiss"
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
@@ -70,33 +70,41 @@
|
||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||
<button
|
||||
v-if="stopBtnState === 'stopping'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
|
||||
:title="$t('aiChat.stopping')"
|
||||
:aria-label="$t('aiChat.stopping')"
|
||||
disabled
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'retry'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
|
||||
:title="$t('aiChat.stopFailedRetry')"
|
||||
:aria-label="$t('aiChat.stopFailedRetry')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'stop'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop"
|
||||
:title="$t('aiChat.stopGenerating')"
|
||||
:aria-label="$t('aiChat.stopGenerating')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="ai-send-btn"
|
||||
:class="{ 'ai-send-btn--active': canSend }"
|
||||
:disabled="!canSend"
|
||||
:aria-label="$t('aiChat.queueSendNow')"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
@@ -128,7 +136,9 @@ import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||
import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation'
|
||||
import { textIdle } from '../../composables/ai/useAiPendingState'
|
||||
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
|
||||
import { extractImageUrlParts } from '../../composables/ai/utils'
|
||||
@@ -158,6 +168,7 @@ const emit = defineEmits<{
|
||||
|
||||
const store = useAiStore()
|
||||
const projectsStore = useProjectStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const inputText = ref('')
|
||||
@@ -803,6 +814,33 @@ function selectMention(item: MentionItem) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── 列表续写开关(设计:docs/02-架构设计/专项设计/输入框列表预览与自动编号-设计-2026-10-10.md,P1=V0 零视觉) ──
|
||||
// appSettings 键 `df-ai-list-input`,默认关:关=完全回退现状 textarea 行为,零残留。
|
||||
// 逻辑层为纯函数(useListContinuation),此处只做接线(键分支 + 应用编辑 + @span 平移)。
|
||||
const listInputEnabled = computed(() => appSettings.get<boolean>('df-ai-list-input', false))
|
||||
|
||||
/** 当前光标位(textarea 未挂载时退化为文末) */
|
||||
function caretOffset(): number {
|
||||
return inputEl.value?.selectionStart ?? inputText.value.length
|
||||
}
|
||||
|
||||
/** 应用列表续写/退出的编辑结果:改文本 → 显式平移 @span(程序赋值不派发 input)→ 回光标 + 重算高度 */
|
||||
function applyListEdit(r: { text: string; caret: number }): void {
|
||||
const old = inputText.value
|
||||
inputText.value = r.text
|
||||
// 程序赋值不触发 input 事件(见 onInputText 说明),mention span 须手动平移,同 shiftMentionSpans 调用范式
|
||||
shiftMentionSpans(old, r.text)
|
||||
nextTick(() => {
|
||||
const el = inputEl.value
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.setSelectionRange(r.caret, r.caret)
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
|
||||
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
|
||||
@@ -895,6 +933,28 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 列表续写(开关 df-ai-list-input,默认关):空项退格退出 / Shift+Enter 续写补号。
|
||||
// 插在技能·@ 浮层导航之后(浮层开时不拦,用户意图是选实体)、Enter 发送之前;
|
||||
// 只绑 Shift+Enter(换行链),Enter 发送语义不变。关闭时整段短路 → 零行为变更。
|
||||
if (listInputEnabled.value && !skillOpen.value && !mentionOpen.value) {
|
||||
const withMod = e.ctrlKey || e.metaKey || e.altKey
|
||||
if (e.key === 'Backspace' && !e.shiftKey && !withMod) {
|
||||
const r = exitListOnBackspace(inputText.value, caretOffset())
|
||||
if (r) {
|
||||
e.preventDefault()
|
||||
applyListEdit(r)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && e.shiftKey && !withMod) {
|
||||
const r = continueList(inputText.value, caretOffset())
|
||||
if (r) {
|
||||
e.preventDefault()
|
||||
applyListEdit(r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
}"
|
||||
tabindex="0"
|
||||
@click="onSelectSearchResult(conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
|
||||
@@ -140,7 +140,7 @@
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
tabindex="0"
|
||||
@click="beginSwitch(conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<input
|
||||
@@ -220,6 +220,7 @@
|
||||
:title="t('aiChat.recoverableHint')"
|
||||
@click="onRecover(conv.id)"
|
||||
@keydown.enter.prevent="onRecover(conv.id)"
|
||||
@keydown.space.prevent="onRecover(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<span class="ai-conv-item-title">
|
||||
@@ -429,13 +430,19 @@ function onSearchKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
|
||||
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发
|
||||
function onItemKeydown(e: KeyboardEvent, convId: string) {
|
||||
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。
|
||||
// renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名,
|
||||
// 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例;归档项不支持重命名故不传该标志)。
|
||||
function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
beginSwitch(convId)
|
||||
} else if (renamable && e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
const conv = store.state.conversations.find(c => c.id === convId)
|
||||
if (conv) startRename(conv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,6 +798,8 @@ body.ai-sidebar-resizing * {
|
||||
font-size: 12px;
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* 聚焦可见环:无边框输入聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
.ai-conv-search-input::placeholder {
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
@@ -916,6 +925,8 @@ body.ai-sidebar-resizing * {
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
.ai-conv-item-time {
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<button
|
||||
v-for="opt in helpActive.options"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="ai-help-required-btn"
|
||||
@click="handleSelectOption(opt)"
|
||||
>{{ opt }}</button>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! (含流式分块、工具卡片、token 用量、错误重试、压缩摘要置顶等聚合逻辑),
|
||||
//! 本组件专注 user,不做 AI 消息渲染,避免两套渲染入口并存导致的死代码与职责混乱。
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
@@ -30,7 +31,7 @@ function leadingSkill(msg: AiMessage): { name: string; rest: string } | null {
|
||||
return { name: m[1], rest: m[2] ?? '' }
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
msg: AiMessage
|
||||
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
||||
canEdit?: boolean
|
||||
@@ -92,6 +93,12 @@ function imageSrc(img: ImageContentPart): string {
|
||||
function imgAlt(img: ImageContentPart): string {
|
||||
return img.alt || t('aiChat.imagePreviewAlt')
|
||||
}
|
||||
|
||||
// 模板中同一渲染内多次引用的纯派生值 → computed 缓存一次求值
|
||||
// (纯函数仅依赖 props.msg / store.state.skills 等响应式源,缓存语义与原先逐次调用等价)
|
||||
const skill = computed(() => leadingSkill(props.msg))
|
||||
const segments = computed(() => segmentUserContent(props.msg))
|
||||
const imageParts = computed(() => msgImageParts(props.msg))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -107,19 +114,19 @@ function imgAlt(img: ImageContentPart): string {
|
||||
</div>
|
||||
<div class="ai-msg-bubble ai-msg-bubble--user">
|
||||
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
|
||||
<template v-if="leadingSkill(msg)">
|
||||
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: leadingSkill(msg)!.name })">⚡ /{{ leadingSkill(msg)!.name }}</span>
|
||||
<template v-if="leadingSkill(msg)!.rest">{{ ' ' + leadingSkill(msg)!.rest }}</template>
|
||||
<template v-if="skill">
|
||||
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span>
|
||||
<template v-if="skill!.rest">{{ ' ' + skill!.rest }}</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
|
||||
<template v-for="(seg, idx) in segments" :key="msg.id + '-seg-' + idx">
|
||||
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
|
||||
<span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="msgImageParts(msg).length">
|
||||
<template v-if="imageParts.length">
|
||||
<div class="ai-msg-images">
|
||||
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
||||
<img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
|
||||
|
||||
+194
-107
@@ -22,7 +22,7 @@ import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
||||
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
|
||||
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
||||
import { getConvAgentFinished, toolEpoch, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
||||
import { aiApi } from '../../api'
|
||||
import ToolCardList from '../ToolCardList.vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
@@ -420,22 +420,16 @@ function onPendingOutOfView(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status),
|
||||
* 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹,
|
||||
* 不触发无意义的收起重算。length 变化(新消息/截断)和 toolCall 状态翻转(新卡/完成/拒绝)
|
||||
* 均能正确触发——与 body 的快照收缩逻辑等价,无需 deep 兜底。
|
||||
* 工具卡自动收起:工具状态翻转(新卡/完成/待审批/拒绝)时收起旧的已完成/已拒绝卡片,
|
||||
* 保留 running/pending/write_file 活跃卡片。
|
||||
*
|
||||
* P0 渲染层:源从 `map(toolCalls→{id,s}) + JSON.stringify` 的 O(n) 指纹改为 toolEpoch 版本计数(O(1))。
|
||||
* 版本号在工具态突变点(aiShared.bumpToolEpoch)自增,免每次工具事件全量遍历长会话数组。
|
||||
* messages.length 变化(新消息/截断/切会话)同样触发(messages 长度与 toolEpoch 任一变即回调)。
|
||||
*/
|
||||
let lastMsgSnapshot = ''
|
||||
watch(
|
||||
() => JSON.stringify({
|
||||
n: store.state.messages.length,
|
||||
tc: store.state.messages.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))),
|
||||
}),
|
||||
(snap) => {
|
||||
if (snap === lastMsgSnapshot) return
|
||||
const isFirst = lastMsgSnapshot === ''
|
||||
lastMsgSnapshot = snap
|
||||
if (isFirst) return
|
||||
[() => store.state.messages.length, toolEpoch],
|
||||
() => {
|
||||
// R10 同门:非跟随态(上滚阅读中)不强制收起正浏览的工具卡
|
||||
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
|
||||
},
|
||||
@@ -683,7 +677,12 @@ const DECO_CACHE_LIMIT = 800
|
||||
function decorateCodeHtml(raw: string): string {
|
||||
if (!raw || !raw.includes('<pre><code')) return raw
|
||||
const hit = _decoCache.get(raw)
|
||||
if (hit !== undefined) return hit
|
||||
if (hit !== undefined) {
|
||||
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
|
||||
_decoCache.delete(raw)
|
||||
_decoCache.set(raw, hit)
|
||||
return hit
|
||||
}
|
||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
|
||||
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
|
||||
@@ -696,7 +695,11 @@ function decorateCodeHtml(raw: string): string {
|
||||
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
|
||||
},
|
||||
)
|
||||
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
||||
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
|
||||
if (_decoCache.size >= DECO_CACHE_LIMIT) {
|
||||
const oldest = _decoCache.keys().next().value
|
||||
if (oldest !== undefined) _decoCache.delete(oldest)
|
||||
}
|
||||
_decoCache.set(raw, out)
|
||||
return out
|
||||
}
|
||||
@@ -1029,117 +1032,173 @@ type RenderItem =
|
||||
* 无过程消息的轮次(单轮问答)零改动原样渲染。
|
||||
*
|
||||
* UX 密度改造:相邻消息间隔 ≥30 分钟或跨天时插居中时间分隔线(timesep 项),
|
||||
* per-block 相对时间已移除。轨距追踪 lastMsgTs 只在消息桶边界推进,
|
||||
* per-block 相对时间已移除。轨距追踪 lastTs(renderItems 局部)只在消息桶边界推进,
|
||||
* 轨迹卡与其内部轮次之间不插线(同轮间隔无叙事价值)。轨迹桶自身也不再发
|
||||
* timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责(两行合一)。
|
||||
*/
|
||||
const TIME_SEP_GAP_MS = 30 * 60 * 1000
|
||||
let lastMsgTs = 0
|
||||
|
||||
/** 与上一条已渲染消息是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插) */
|
||||
function needsTimeSep(ts: number): boolean {
|
||||
if (!lastMsgTs || !ts) return false
|
||||
if (Math.abs(ts - lastMsgTs) >= TIME_SEP_GAP_MS) return true
|
||||
const a = new Date(lastMsgTs)
|
||||
/** 与上一时刻是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插)。
|
||||
* prevTs 由调用方传入(桶级缓存不持跨桶可变态,改参数化保纯)。 */
|
||||
function needsTimeSep(ts: number, prevTs: number): boolean {
|
||||
if (!prevTs || !ts) return false
|
||||
if (Math.abs(ts - prevTs) >= TIME_SEP_GAP_MS) return true
|
||||
const a = new Date(prevTs)
|
||||
const b = new Date(ts)
|
||||
return a.getFullYear() !== b.getFullYear()
|
||||
|| a.getMonth() !== b.getMonth()
|
||||
|| a.getDate() !== b.getDate()
|
||||
}
|
||||
|
||||
/** 需要时插 timesep 项并推进 lastMsgTs(同一出口防漏插/重插) */
|
||||
function pushTimeSepIfNeeded(items: RenderItem[], ts: number): void {
|
||||
if (needsTimeSep(ts)) {
|
||||
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
|
||||
// ── 桶级 memo(P0 渲染层):同签名桶复用同一批 item 对象 ──
|
||||
// 两重收益:①工具事件时 O(n)→O(变更桶)(其余桶命中缓存不重算);②item 对象引用稳定
|
||||
// → 模板 v-memo 的 [item] dep 生效前提(桶未变 → item 同引用 → v-memo 跳过该 item 的 patch)。
|
||||
// 签名覆盖 flushBucket 产出真正消费的全部输入(消息身份/文本/token + 工具态 + 轮号 + 生成态 +
|
||||
// 前桶时刻 + 轨迹展开态);漏字段 → 命中陈旧 item → 局部不刷新,故从宽纳入。LRU 有界(沿用 _decoCache 范式)。
|
||||
const _bucketCache = new Map<string, { items: RenderItem[]; endTs: number }>()
|
||||
const BUCKET_CACHE_LIMIT = 128
|
||||
|
||||
function bucketSig(msgs: AiMessage[], round: number, executing: boolean, prevTs: number, traceExpanded: boolean): string {
|
||||
const parts: string[] = [round + '#' + (executing ? 1 : 0) + '#' + prevTs + '#' + (traceExpanded ? 1 : 0)]
|
||||
for (const m of msgs) {
|
||||
let s = m.id + ':' + m.role + ':' + (m.status ?? '') + ':' + (m.isError ? 1 : 0) + ':' + (m.isRetry ? 1 : 0)
|
||||
+ ':' + (m.timestamp ?? 0) + ':' + (m.model ?? '')
|
||||
+ ':' + (m.content ?? '') + ':' + (m.reasoning_content ?? '')
|
||||
+ ':' + (m.tokenUsage?.prompt ?? m.prompt_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.completion ?? m.completion_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.cache_miss ?? m.prompt_cache_miss_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.is_estimated ? 1 : 0)
|
||||
+ ':' + (m.parts?.length ?? 0) + ':' + (m.mentionSpans?.length ?? 0)
|
||||
for (const t of m.toolCalls ?? []) {
|
||||
s += ',' + t.id + ':' + t.status + ':' + (t.completedAt ?? 0)
|
||||
}
|
||||
parts.push(s)
|
||||
}
|
||||
lastMsgTs = ts
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
/** 无缓存构建一个桶的 item(重活:collectTurnStats/sumTurnTokens/isToolResultJson 均在此)。
|
||||
* 返回 endTs(末条时刻)= 该桶结束后应作为下一桶 prevTs 的值。 */
|
||||
function genBucketItems(
|
||||
msgs: AiMessage[],
|
||||
round: number,
|
||||
executing: boolean,
|
||||
prevTs: number,
|
||||
traceKey: string,
|
||||
traceExpanded: boolean,
|
||||
): { items: RenderItem[]; endTs: number } {
|
||||
const items: RenderItem[] = []
|
||||
const cur = msgs[msgs.length - 1]
|
||||
const pushSep = (ts: number) => {
|
||||
if (needsTimeSep(ts, prevTs)) items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
|
||||
}
|
||||
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
|
||||
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
|
||||
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
|
||||
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
|
||||
if (executing) {
|
||||
const prev = msgs.slice(0, -1)
|
||||
if (prev.length > 0) {
|
||||
pushSep(msgs[0].timestamp)
|
||||
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
|
||||
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
|
||||
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs) })
|
||||
} else {
|
||||
pushSep(cur.timestamp)
|
||||
}
|
||||
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
|
||||
return { items, endTs: cur.timestamp }
|
||||
}
|
||||
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
||||
let fi = -1
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const c = msgs[i].content
|
||||
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
|
||||
}
|
||||
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
|
||||
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
|
||||
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
|
||||
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
|
||||
const traceMsgs = msgs.filter(m => m !== finalMsg)
|
||||
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
|
||||
if (traceMsgs.length > 0) {
|
||||
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
|
||||
let lastCompleted = 0
|
||||
for (const m of traceMsgs) {
|
||||
for (const tc of m.toolCalls || []) {
|
||||
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
|
||||
}
|
||||
}
|
||||
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
|
||||
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
|
||||
items.push({
|
||||
kind: 'trace', key: traceKey,
|
||||
round,
|
||||
stats: collectTurnStats(traceMsgs),
|
||||
startTs: traceMsgs[0].timestamp,
|
||||
endTs: Math.max(lastCompleted, finalMsg.timestamp),
|
||||
tokens: sumTurnTokens(traceMsgs),
|
||||
traceMsgs, expanded: traceExpanded,
|
||||
})
|
||||
} else {
|
||||
pushSep(finalMsg.timestamp)
|
||||
}
|
||||
// 结果/过程分离(2026-10-10 用户口径:过程太多压住结果、密度过大):
|
||||
// 气泡**只放最终结果**(finalMsg 正文);本桶其余轮的叙述与工具卡全收进「执行轨迹卡」(默认一行
|
||||
// 收起,卡内展开可看逐轮全文与工具卡)——既不再"流式后正文消失",又不再"一条消息回 N 个气泡"。
|
||||
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
|
||||
return { items, endTs: finalMsg.timestamp }
|
||||
}
|
||||
|
||||
/** 桶 memo 取用:签名命中回同一对象数组(引用稳定);未命中构建 + 有界 LRU 写入(命中置末)。 */
|
||||
function buildBucket(msgs: AiMessage[], round: number, executing: boolean, prevTs: number): { items: RenderItem[]; endTs: number } {
|
||||
const traceKey = 'trace-' + msgs[0].id
|
||||
const traceExpanded = expandedTraceIds.value.has(traceKey)
|
||||
const sig = bucketSig(msgs, round, executing, prevTs, traceExpanded)
|
||||
const hit = _bucketCache.get(sig)
|
||||
if (hit) {
|
||||
_bucketCache.delete(sig)
|
||||
_bucketCache.set(sig, hit)
|
||||
return hit
|
||||
}
|
||||
const built = genBucketItems(msgs, round, executing, prevTs, traceKey, traceExpanded)
|
||||
if (_bucketCache.size >= BUCKET_CACHE_LIMIT) {
|
||||
const oldest = _bucketCache.keys().next().value
|
||||
if (oldest !== undefined) _bucketCache.delete(oldest)
|
||||
}
|
||||
_bucketCache.set(sig, built)
|
||||
return built
|
||||
}
|
||||
|
||||
const renderItems = computed<RenderItem[]>(() => {
|
||||
const items: RenderItem[] = []
|
||||
let bucket: AiMessage[] = []
|
||||
lastMsgTs = 0
|
||||
let lastTs = 0
|
||||
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
|
||||
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
|
||||
let traceCount = 0
|
||||
|
||||
const pushSep = (ts: number) => {
|
||||
if (needsTimeSep(ts, lastTs)) {
|
||||
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
|
||||
}
|
||||
lastTs = ts
|
||||
}
|
||||
|
||||
const flushBucket = () => {
|
||||
const msgs = bucket.filter(shouldRenderMsg)
|
||||
bucket = []
|
||||
if (msgs.length === 0) return
|
||||
|
||||
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
|
||||
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
|
||||
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
|
||||
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
|
||||
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
|
||||
const cur = msgs[msgs.length - 1]
|
||||
if (isViewingGenerating.value && cur.id === lastStoreId) {
|
||||
const prev = msgs.slice(0, -1)
|
||||
if (prev.length > 0) {
|
||||
pushTimeSepIfNeeded(items, msgs[0].timestamp)
|
||||
traceCount++
|
||||
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
|
||||
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
|
||||
// 原 step-{id} 与 trace-{id} key 不同 → 完成瞬间整块轨迹 DOM 重建 → 闪烁。
|
||||
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
|
||||
} else {
|
||||
pushTimeSepIfNeeded(items, cur.timestamp)
|
||||
}
|
||||
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
|
||||
lastMsgTs = cur.timestamp
|
||||
return
|
||||
}
|
||||
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
||||
let fi = -1
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const c = msgs[i].content
|
||||
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
|
||||
}
|
||||
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
|
||||
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
|
||||
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
|
||||
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
|
||||
const traceMsgs = msgs.filter(m => m !== finalMsg)
|
||||
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
|
||||
if (traceMsgs.length > 0) {
|
||||
const key = 'trace-' + traceMsgs[0].id
|
||||
const expanded = expandedTraceIds.value.has(key)
|
||||
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
|
||||
let lastCompleted = 0
|
||||
for (const m of traceMsgs) {
|
||||
for (const tc of m.toolCalls || []) {
|
||||
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
|
||||
}
|
||||
}
|
||||
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
|
||||
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
|
||||
lastMsgTs = traceMsgs[0].timestamp
|
||||
traceCount++
|
||||
items.push({
|
||||
kind: 'trace', key,
|
||||
round: traceCount,
|
||||
stats: collectTurnStats(traceMsgs),
|
||||
startTs: traceMsgs[0].timestamp,
|
||||
endTs: Math.max(lastCompleted, finalMsg.timestamp),
|
||||
tokens: sumTurnTokens(traceMsgs),
|
||||
traceMsgs, expanded,
|
||||
})
|
||||
} else {
|
||||
pushTimeSepIfNeeded(items, finalMsg.timestamp)
|
||||
}
|
||||
// 正文不折叠(2026-10-10 根因修复):此前整轮(含模型正文)折进轨迹卡默认收起 → 流式期间
|
||||
// 能看到正文、轮末正文随卡收起,用户表象为「气泡被清空/进入等待」,历次在 flush/清空/骨架层
|
||||
// 修补均无效。现把过程消息里**有正文**的各成一个常显气泡(剥掉 toolCalls——工具卡仍归轨迹卡,
|
||||
// 不重复渲染),轨迹卡只折叠工具步骤与思考。
|
||||
for (const m of traceMsgs) {
|
||||
const c = m.content
|
||||
if (m.role === 'assistant' && c && c.trim() && !isToolResultJson(c)) {
|
||||
items.push({ kind: 'msg', key: 'm-' + m.id, msg: { ...m, toolCalls: undefined } })
|
||||
}
|
||||
}
|
||||
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
|
||||
lastMsgTs = finalMsg.timestamp
|
||||
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId
|
||||
// 轨迹/步骤项产生时轮号自增(仅 msgs.length>1 会产出 step/trace;轮号计入桶签名,
|
||||
// 前序桶变化致轮号位移时该桶正确重算)
|
||||
const round = msgs.length > 1 ? ++traceCount : traceCount
|
||||
const built = buildBucket(msgs, round, executing, lastTs)
|
||||
for (const it of built.items) items.push(it)
|
||||
lastTs = built.endTs
|
||||
}
|
||||
|
||||
for (const seg of messageSegments.value) {
|
||||
@@ -1149,7 +1208,7 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
items.push({ kind: 'sep', key: seg.key, seg })
|
||||
if (expandedSegmentIds.value.has(seg.key)) {
|
||||
for (const m of seg.msgs) {
|
||||
pushTimeSepIfNeeded(items, m.timestamp)
|
||||
pushSep(m.timestamp)
|
||||
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
|
||||
}
|
||||
}
|
||||
@@ -1161,7 +1220,7 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
bucket.push(m)
|
||||
} else {
|
||||
flushBucket()
|
||||
pushTimeSepIfNeeded(items, m.timestamp)
|
||||
pushSep(m.timestamp)
|
||||
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
|
||||
}
|
||||
}
|
||||
@@ -1186,6 +1245,23 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
// ── v-memo 依赖数组(P0 渲染层,内联在模板 v-for 的 v-memo 处) ──
|
||||
// renderItems(computed)已读源经 [item] 自动失效(桶 memo 保证桶未变时 item 引用稳定 → 跳过 patch);
|
||||
// v-memo 额外补「renderItems 未读、但渲染该 item 时读了」的兄弟响应式源 —— 漏一个 → 该项局部不刷新:
|
||||
// - expandedThinkIds / expandedJsonIds / expandedLongIds:思考 pill / JSON 块 / 长文折叠展开态
|
||||
// (整体 Set 引用纳入;useToggleSet 每次 toggle 换新 Set → 引用变 → 该项重渲);
|
||||
// - tokenPopoverMsgId / editingMsgId / agentPeekLoadingId:token 面板 / user 编辑按钮 / 子代理 peek 转圈;
|
||||
// - store.state.streaming / isViewingGenerating:光标、user 编辑按钮 can-edit、流式块闸直接读的源
|
||||
// (非 renderItems 已读,须显式纳入,否则流式启停该 item 不刷新);
|
||||
// - _mdRenderKey:marked 就绪后 forceUpdate 递增、经 renderMdDecorated 读;taskTitleCache:任务 chip 标题懒加载。
|
||||
// - 流式文本源(定向 dep):流式 delta 只改 currentText→streamingBlocks→decoratedStreamingBlocks,
|
||||
// 不改任何消息 content、不改桶签名 → item 引用稳定。若此处不纳入流式源,流式 item 的 v-memo 恒命中,
|
||||
// 子树跳过 patch,气泡冻结在首个空帧(要等工具事件/收尾才整段出现)。
|
||||
// 只对「正在渲染流式块的 item」(isLastAi + streaming,即模板内 v-if 渲染 decoratedStreamingBlocks 的那一项)
|
||||
// 纳入 decoratedStreamingBlocks(每 rAF 换新数组引用 → dep 失效 → 该项重渲),其余 item 取常量 '' 占位,
|
||||
// 使 v-memo 仅在流式项失效,空闲/历史项不重渲的 P0 收益不受影响。
|
||||
// 注:vue-tsc 不解析 v-memo 表达式,抽独立函数会触发 noUnusedLocals 误报,故直接内联数组。
|
||||
|
||||
// ── S1 子代理完成卡(AiAgentFinished) ──
|
||||
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
|
||||
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
|
||||
@@ -1502,7 +1578,7 @@ defineExpose({
|
||||
每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染;
|
||||
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
|
||||
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
|
||||
<template v-for="item in renderItems" :key="item.key">
|
||||
<template v-for="item in renderItems" :key="item.key" v-memo="[item, store.state.streaming, isViewingGenerating, expandedThinkIds, expandedJsonIds, expandedLongIds, tokenPopoverMsgId, editingMsgId, agentPeekLoadingId, _mdRenderKey, taskTitleCache, item.kind === 'msg' && isLastAi(item.msg) && store.state.streaming ? decoratedStreamingBlocks : '']">
|
||||
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
|
||||
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
|
||||
<div v-if="item.kind === 'trace'" class="ai-msg-slot ai-msg-slot--proc">
|
||||
@@ -1526,8 +1602,12 @@ defineExpose({
|
||||
v-for="ref in traceTaskRefs(item.traceMsgs)"
|
||||
:key="ref.taskId"
|
||||
class="ai-trace-task-chip"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="$t('ai.trace.taskRef', { name: taskTitleOf(ref.taskId) })"
|
||||
@click.stop="openTask(ref.taskId)"
|
||||
@keydown.enter.stop.prevent="openTask(ref.taskId)"
|
||||
@keydown.space.stop.prevent="openTask(ref.taskId)"
|
||||
>🔗 {{ taskTitleOf(ref.taskId) }}</span>
|
||||
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
|
||||
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
|
||||
@@ -1550,11 +1630,18 @@ defineExpose({
|
||||
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
|
||||
v-if="expandedThinkIds.has(m.id) && m.reasoning_content?.trim()"
|
||||
class="ai-think-full ai-md"
|
||||
v-html="renderMdDecorated(m.content)"
|
||||
v-html="renderMdDecorated(m.reasoning_content)"
|
||||
></div>
|
||||
</template>
|
||||
<!-- 轮内正文(该轮叙述):随卡默认收起(结果优先、控密度),展开轨迹卡即见全文。
|
||||
复用 .ai-think-full 的弱化样式(12px/次要色/左缩进),零新增 CSS。 -->
|
||||
<div
|
||||
v-if="m.role === 'assistant' && m.content && m.content.trim()"
|
||||
class="ai-think-full ai-md"
|
||||
v-html="renderMdDecorated(m.content)"
|
||||
></div>
|
||||
<ToolCardList
|
||||
v-if="m.toolCalls && m.toolCalls.length > 0"
|
||||
ref="toolCardListRef"
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<div v-if="budgetOpen" class="ai-boundary-budget">
|
||||
<div class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'rounds' }">
|
||||
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
|
||||
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked || roundsUnlimited" @input="budgetErrField = ''" />
|
||||
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :aria-label="t('ai.taskBoundary.budgetRounds')" :disabled="locked || roundsUnlimited" @input="budgetErrField = ''" />
|
||||
<label class="ai-boundary-unlimited">
|
||||
<input v-model="roundsUnlimited" type="checkbox" :disabled="locked" />
|
||||
<span>{{ t('ai.taskBoundary.budgetUnlimited') }}</span>
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
<div class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'wallSecs' }">
|
||||
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
|
||||
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked || wallUnlimited" @input="budgetErrField = ''" />
|
||||
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :aria-label="t('ai.taskBoundary.budgetWallSecs')" :disabled="locked || wallUnlimited" @input="budgetErrField = ''" />
|
||||
<label class="ai-boundary-unlimited">
|
||||
<input v-model="wallUnlimited" type="checkbox" :disabled="locked" />
|
||||
<span>{{ t('ai.taskBoundary.budgetUnlimited') }}</span>
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
|
||||
<span v-else class="ai-goal-check">✓</span>
|
||||
<span class="ai-goal-text">{{ g.text }}</span>
|
||||
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
|
||||
<button class="ai-goal-remove" @click.stop="removeGoal(i)" :aria-label="$t('common.delete')">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -222,7 +222,7 @@
|
||||
</button>
|
||||
<Transition name="ai-pop">
|
||||
<div v-if="historyExpanded" class="ai-history-inline-list">
|
||||
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
|
||||
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" role="button" tabindex="0" @click="scrollToMessage(h.id)" @keydown.enter.prevent="scrollToMessage(h.id)" @keydown.space.prevent="scrollToMessage(h.id)">
|
||||
<span class="ai-history-index">{{ h.num }}</span>
|
||||
<span class="ai-history-text">{{ h.text }}</span>
|
||||
<span class="ai-history-time">{{ h.time }}</span>
|
||||
@@ -238,7 +238,7 @@
|
||||
</button>
|
||||
<Transition name="ai-pop">
|
||||
<div v-if="summaryExpanded" class="ai-summary-inline-list">
|
||||
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
|
||||
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" role="button" tabindex="0" @click="scrollToMessage(s.id)" @keydown.enter.prevent="scrollToMessage(s.id)" @keydown.space.prevent="scrollToMessage(s.id)">
|
||||
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
|
||||
<p class="ai-summary-preview">{{ s.text }}</p>
|
||||
</div>
|
||||
@@ -309,7 +309,7 @@ import { aiApi } from '../../api/ai'
|
||||
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
|
||||
import { convRounds } from '../../composables/ai/aiShared'
|
||||
import { switchConversation } from '../../composables/ai/useAiConversations'
|
||||
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
|
||||
import type { ModelConfig, GoalEntry } from '../../api/types'
|
||||
|
||||
defineProps<{
|
||||
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
|
||||
@@ -595,6 +595,7 @@ const goalsExpanded = ref(false)
|
||||
|
||||
// ── 📋 历史消息(当前会话 user 消息列表) ──
|
||||
interface HistoryItem { id: string; text: string; time: string; num: number }
|
||||
interface SummaryItem { id: string; text: string }
|
||||
/** 时间显示:今日 HH:mm / 昨天 HH:mm / MM-DD HH:mm */
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
@@ -616,17 +617,45 @@ function fmtTime(ts: number): string {
|
||||
}
|
||||
const MAX_HISTORY = 20
|
||||
const MAX_CHARS = 80
|
||||
const historyMsgs = computed<HistoryItem[]>(() => {
|
||||
const allUserMsgs = store.state.messages
|
||||
.filter((m): m is AiMessage & { timestamp: number } => m.role === 'user' && !!m.content && !!m.timestamp)
|
||||
.map((m, idx) => ({
|
||||
id: m.id,
|
||||
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
|
||||
time: fmtTime(m.timestamp),
|
||||
num: idx + 1,
|
||||
}))
|
||||
return allUserMsgs.slice(-MAX_HISTORY).reverse()
|
||||
// 单次遍历 messages,一次产出「历史(user 消息)/摘要(system 消息)/项目名」三路派生,
|
||||
// 替代原先三处 computed 各自全量扫描(historyMsgs/summaryMsgs/contextInfo)。
|
||||
// 三路的筛选条件、收集顺序、截断与反转语义与拆分前逐项一致。
|
||||
const msgDerived = computed(() => {
|
||||
const history: HistoryItem[] = []
|
||||
const summaries: SummaryItem[] = []
|
||||
const projectLabels = new Set<string>()
|
||||
for (const m of store.state.messages) {
|
||||
if (m.role === 'user') {
|
||||
// 项目名收集只认 user 的 mentionSpans(不设 content/timestamp 门槛),
|
||||
// 与原 contextInfo 的独立遍历同条件,故与下面的历史筛选互不干扰。
|
||||
if (m.mentionSpans?.length) {
|
||||
for (const sp of m.mentionSpans) {
|
||||
if (sp.kind === 'project' && sp.label) projectLabels.add(sp.label)
|
||||
}
|
||||
}
|
||||
if (m.content && m.timestamp) {
|
||||
history.push({
|
||||
id: m.id,
|
||||
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
|
||||
time: fmtTime(m.timestamp),
|
||||
num: history.length + 1,
|
||||
})
|
||||
}
|
||||
} else if (m.role === 'system' && m.content) {
|
||||
summaries.push({
|
||||
id: m.id,
|
||||
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
history: history.slice(-MAX_HISTORY).reverse(),
|
||||
summaries,
|
||||
projectNames: Array.from(projectLabels).join(', '),
|
||||
}
|
||||
})
|
||||
const historyMsgs = computed<HistoryItem[]>(() => msgDerived.value.history)
|
||||
const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
|
||||
const historyExpanded = ref(false)
|
||||
|
||||
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
|
||||
@@ -672,15 +701,7 @@ function onBottomToolsClickOutside(e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
// ── 📄 上下文摘要(压缩/归档的系统消息) ──
|
||||
interface SummaryItem { id: string; text: string }
|
||||
const summaryMsgs = computed<SummaryItem[]>(() => {
|
||||
return store.state.messages
|
||||
.filter(m => m.role === 'system' && !!m.content)
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
|
||||
}))
|
||||
})
|
||||
// SummaryItem 类型与 summaryMsgs 派生已上移至历史区(与 historyMsgs 共享单次遍历)。
|
||||
const summaryExpanded = ref(false)
|
||||
|
||||
// ── L3 完整上下文 ──
|
||||
@@ -707,22 +728,11 @@ function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'runni
|
||||
const contextInfo = computed<ContextInfo>(() => {
|
||||
const convId = store.state.activeConversationId
|
||||
const conv = convId ? store.state.conversations.find(c => c.id === convId) : undefined
|
||||
// 从所有 user 消息中收集 mentionSpans(kind === 'project'),去重
|
||||
const projectLabels = new Set<string>()
|
||||
for (const m of store.state.messages) {
|
||||
if (m.role === 'user' && m.mentionSpans?.length) {
|
||||
for (const sp of m.mentionSpans) {
|
||||
if (sp.kind === 'project' && sp.label) {
|
||||
projectLabels.add(sp.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
goals: conv?.pinned_goals ?? [],
|
||||
summaryCount: summaryMsgs.value.length,
|
||||
summaryCount: msgDerived.value.summaries.length,
|
||||
osInfo: 'Windows 11 / PowerShell',
|
||||
projectNames: Array.from(projectLabels).join(', ') || t('aiChat.none'),
|
||||
projectNames: msgDerived.value.projectNames || t('aiChat.none'),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -916,6 +926,8 @@ onBeforeUnmount(() => {
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 聚焦可见环:聚焦态无边框变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-model-select:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
.ai-model-select--compact {
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user