From 2c2b2d710ed9f6e92f5c035b25bbf212f42de5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 1 Jul 2026 12:21:40 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20MessageList=20=E6=8B=86?= =?UTF-8?q?=E5=88=86(=E6=8F=90=E5=8F=96=20MessageItem+useMessageScroll,=20?= =?UTF-8?q?1552->1386=E8=A1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮) - 提取 MessageItem.vue(按 role 渲染单条消息) - 清理 MessageList.vue 中已移至子组件/ composable 的死代码 --- Batch.md | 32 ++-- src/components/ai/MessageItem.vue | 171 +++++++++++++++++++ src/components/ai/MessageList.vue | 220 +++---------------------- src/composables/ai/useMessageScroll.ts | 89 ++++++++++ 4 files changed, 307 insertions(+), 205 deletions(-) create mode 100644 src/components/ai/MessageItem.vue create mode 100644 src/composables/ai/useMessageScroll.ts diff --git a/Batch.md b/Batch.md index ed0e474..b4e0fc6 100644 --- a/Batch.md +++ b/Batch.md @@ -374,20 +374,28 @@ --- +## Batch 30 — 代码质量收尾 + +- **提交**: `6771d39` +- **内容**: + - 设置页搜索索引补全(新增 dataDir 项) + - 编译警告清理(df-storage OptionalExtension + df-ai join_set) + - i18n 核验(移除 zh-CN/fileExplorer.ts 未使用的 loadMore key) + - String 替 newtype(5 个 branded ID 类型:ProjectId/TaskId/ConvId/ModuleId/MessageId) +- **验证**: cargo check + vue-tsc + vite build 通过 + +## Batch 31 — God 文件拆分第一步(MessageList) + +- **提交**: `当前待提交` +- **内容**: + - 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行) + - 提取 MessageItem.vue(单条消息按 role 渲染,171 行) + - MessageList.vue 从 1552 行降至 1386 行(-166 行) +- **验证**: vue-tsc + vite build 通过 + ## 后续规划批次(待推进) -### Batch 30 — 代码质量收尾 -1. 设置页搜索索引核验补全 -2. 编译警告清理(df-storage / df-ai) -3. i18n 遗漏翻译核验 -4. String 替 newtype(5处纯类型重构) - -### Batch 31 — God 文件拆分第一步(MessageList) -1. 提取 MessageItem.vue(单条消息渲染) -2. 提取 useMessageScroll.ts(滚动控制) -3. MessageList.vue 从 1552 行降至 ~600 行 - -### Batch 32 — God 文件拆分第二步(ChatInput) +### Batch 32 — ChatInput 拆分 1. 提取 SkillMention.vue(/@ 联想浮层) 2. 提取 ImageInput.vue(图片粘贴/拖拽) 3. ChatInput.vue 从 1183 行降至 ~500 行 diff --git a/src/components/ai/MessageItem.vue b/src/components/ai/MessageItem.vue new file mode 100644 index 0000000..3ac27c2 --- /dev/null +++ b/src/components/ai/MessageItem.vue @@ -0,0 +1,171 @@ + + + diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 9ef8eb8..2395bf1 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -19,9 +19,11 @@ import { useI18n } from 'vue-i18n' import { useAiStore } from '../../stores/ai' import { useMarkdown } from '../../composables/useMarkdown' import { useAppSettingsStore } from '../../stores/appSettings' +import { useMessageScroll } from '../../composables/ai/useMessageScroll' import ToolCardList from '../ToolCardList.vue' +import MessageItem from './MessageItem.vue' import { formatRelative, formatDate } from '../../utils/time' -import type { AiMessage, ContentPart, MentionSpan } from '../../api/types' +import type { AiMessage } from '../../api/types' defineProps<{ /** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */ @@ -198,7 +200,7 @@ function scheduleStreamParse(text: string) { // B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。 // 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF, // 滚到渲染前高度)致"差一截"。 - if (isFollowingBottom) scrollToBottom() + if (isFollowingBottom.value) scrollToBottom() }) }) } @@ -210,62 +212,6 @@ function renderContent(msg: AiMessage): string { return renderMd(msg.content) } -// ── Input Augmentation: 用户消息按 mention 区间切段(弃正则,纯元数据驱动) ── -// 解决 🔴HIGH-2:正则误匹配(用户手打 [项目: x] 格式 / AI 模仿格式 / 项目名含 ] / 嵌套)。 -// 按 msg.mentionSpans(无则纯文本一段)按 start 升序切段:text → chip 替换。 -// 每 span 安全校验:content.slice(start, start+length) === span.label 否则降级为 text -// (用户编辑破坏区间,如改了项目名/删了字符致偏移失效)。重叠/越界 span 也降级。 -type UserContentSegment = - | { type: 'text'; text: string } - | { type: 'chip'; span: MentionSpan } - -function segmentUserContent(msg: AiMessage): UserContentSegment[] { - const content = msg.content - const spans = msg.mentionSpans - // 无 mentionSpans(历史消息/纯文本)→ 一段 text 零回归 - if (!spans || spans.length === 0) { - return [{ type: 'text', text: content }] - } - - // 按 start 升序排序(稳定;不影响原数组) - const sorted = [...spans].sort((a, b) => a.start - b.start) - - const segments: UserContentSegment[] = [] - let cursor = 0 // 已消费到的字符偏移 - - for (const span of sorted) { - const start = span.start - const end = start + span.length - - // 越界:起点/终点超出 content 长度 → 降级跳过(区间已失效) - if (start < 0 || end > content.length) continue - // 与前段重叠:起点在已消费 cursor 之前 → 降级跳过(防区间相互覆盖产生错乱) - if (start < cursor) continue - // 安全校验:切片内容必须 === span.label(用户编辑破坏区间则不等 → 降级跳过该 span) - if (content.slice(start, end) !== span.label) continue - - // 前导 text(若有) - if (start > cursor) { - segments.push({ type: 'text', text: content.slice(cursor, start) }) - } - // chip 段 - segments.push({ type: 'chip', span }) - cursor = end - } - - // 收尾 tail text(末个 span 之后剩余) - if (cursor < content.length) { - segments.push({ type: 'text', text: content.slice(cursor) }) - } - - // 极端:所有 span 全降级且 content 非空 → segments 可能为空(无前导/tail),补一段完整 text - if (segments.length === 0 && content.length > 0) { - return [{ type: 'text', text: content }] - } - - return segments -} - // ── UX-2025-20: 空状态示例问题 ── // 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。 const examplePrompts = [ @@ -325,91 +271,20 @@ function isLastUser(msg: AiMessage): boolean { return false } -/** ContentPart 图片片类型(discriminator type==='image' 的联合成员) */ -type ImageContentPart = Extract - -/** 取消息的图片 ContentPart 片(仅 type==='image');空数组=无图 */ -function msgImageParts(msg: AiMessage): ImageContentPart[] { - if (!msg.parts) return [] - return msg.parts.filter((p): p is ImageContentPart => p.type === 'image') -} - -/** ContentPart.image → :base64 模式拼 data URI,url 模式原样 */ -function imageSrc(img: ImageContentPart): string { - if (img.base64) { - // media_type 缺失兜底 image/png(provider 转发端点需完整 data URI) - const mt = img.media_type || 'image/png' - return `data:${mt};base64,${img.base64}` - } - return img.url || '' -} - -/** ContentPart.image → alt 文案 */ -function imgAlt(img: ImageContentPart): string { - return img.alt || t('aiChat.imagePreviewAlt') -} - /** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */ function formatTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) } -// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存) ── -// 用户是否已在底部附近(上滑查看历史时不强制拉回) -function isNearBottom(): boolean { - const el = messagesContainer.value - if (!el) return true - return el.scrollHeight - el.scrollTop - el.clientHeight < 80 -} - -// 回到底部按钮:内容溢出且不在底部时显示 -const showBackToBottom = ref(false) -// UX-260616-03: 跟随阅读位置自动收起 —— 用户滚近底部(在读最新内容)时, -// 收起不再活跃的旧卡(视为已读)。用 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 } - const overflow = el.scrollHeight - el.clientHeight > 100 - showBackToBottom.value = overflow && !isNearBottom() -} -function onMessagesScroll() { - refreshBackToBottom() - // UX-260616-03: 滚近底部 → 收起已读旧卡(边沿触发,防抖) - const near = isNearBottom() - if (near && !wasNearBottom) { - 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', - }) - showBackToBottom.value = false - } -} - -// 消息/流式变化时:跟随意图则自动滚;否则只刷新回底按钮(不强制打断) -// 用 isFollowingBottom 锁存而非 isNearBottom 瞬时测量,避免流式高频下单帧突破阈值误判失跟 -function onContentChange() { - if (isFollowingBottom) { - nextTick(scrollToBottom) - } else { - refreshBackToBottom() - } -} +// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 useMessageScroll) ── +const { + showBackToBottom, + isFollowingBottom, + onMessagesScroll, + scrollToBottom, + onContentChange, + setFollowing, +} = useMessageScroll(messagesContainer) watch(() => store.state.messages.length, onContentChange) // SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行) @@ -840,10 +715,8 @@ const renderItems = computed(() => { // UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。 watch(() => store.state.activeConversationId, () => { - // SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists - wasNearBottom = true - // B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false) - isFollowingBottom = true + // SW-260618-18: 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部) + setFollowing(true) // BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue // 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。 // 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations) @@ -875,7 +748,7 @@ defineExpose({