优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)
This commit is contained in:
@@ -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 小字 + 上下留白),不抢正常消息视觉焦点。 */
|
||||
|
||||
Reference in New Issue
Block a user