重构: MessageList 拆分(提取 MessageItem+useMessageScroll, 1552->1386行)
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮) - 提取 MessageItem.vue(按 role 渲染单条消息) - 清理 MessageList.vue 中已移至子组件/ composable 的死代码
This commit is contained in:
32
Batch.md
32
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 — 代码质量收尾
|
### Batch 32 — ChatInput 拆分
|
||||||
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)
|
|
||||||
1. 提取 SkillMention.vue(/@ 联想浮层)
|
1. 提取 SkillMention.vue(/@ 联想浮层)
|
||||||
2. 提取 ImageInput.vue(图片粘贴/拖拽)
|
2. 提取 ImageInput.vue(图片粘贴/拖拽)
|
||||||
3. ChatInput.vue 从 1183 行降至 ~500 行
|
3. ChatInput.vue 从 1183 行降至 ~500 行
|
||||||
|
|||||||
171
src/components/ai/MessageItem.vue
Normal file
171
src/components/ai/MessageItem.vue
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
//! 单条消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
|
||||||
|
//!
|
||||||
|
//! 职责:按 role(user/assistant/system)渲染单条消息气泡,
|
||||||
|
//! 含用户消息的 Mention chip + 图片、助理消息的流式分块/完成态、
|
||||||
|
//! 系统消息的压缩摘要。不涉滚动/折叠段/工具卡片等聚合逻辑。
|
||||||
|
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useMarkdown } from '../../composables/useMarkdown'
|
||||||
|
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { renderMd } = useMarkdown()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
msg: AiMessage
|
||||||
|
/** 是否为末条 assistant 消息(流式分块渲染判定) */
|
||||||
|
isLastAi: boolean
|
||||||
|
/** 是否为末条 user 消息(编辑按钮显示) */
|
||||||
|
isLastUser: boolean
|
||||||
|
/** 当前正在编辑的消息 id(隐藏编辑按钮) */
|
||||||
|
editingMsgId?: string | null
|
||||||
|
/** 流式渲染分块(末条 assistant 当前流式内容的分块结果) */
|
||||||
|
streamingBlocks?: { html: string; key: string }[]
|
||||||
|
/** 是否正在生成(控制光标闪烁) */
|
||||||
|
isViewingGenerating?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'start-edit', msg: AiMessage): void
|
||||||
|
(e: 'copy', msg: AiMessage): void
|
||||||
|
(e: 'regenerate', msg: AiMessage): void
|
||||||
|
(e: 'retry', msg: AiMessage): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/// 完成/历史消息用 renderMd(整段 string)
|
||||||
|
function renderContent(msg: AiMessage): string {
|
||||||
|
return renderMd(msg.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input Augmentation: 按 mention 区间切段 ──
|
||||||
|
type UserContentSegment =
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'chip'; span: MentionSpan }
|
||||||
|
|
||||||
|
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
|
||||||
|
const content = msg.content
|
||||||
|
const spans = msg.mentionSpans
|
||||||
|
if (!spans || spans.length === 0) {
|
||||||
|
return [{ type: 'text', text: content }]
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if (start < 0 || end > content.length) continue
|
||||||
|
if (start < cursor) continue
|
||||||
|
if (content.slice(start, end) !== span.label) continue
|
||||||
|
if (start > cursor) {
|
||||||
|
segments.push({ type: 'text', text: content.slice(cursor, start) })
|
||||||
|
}
|
||||||
|
segments.push({ type: 'chip', span })
|
||||||
|
cursor = end
|
||||||
|
}
|
||||||
|
if (cursor < content.length) {
|
||||||
|
segments.push({ type: 'text', text: content.slice(cursor) })
|
||||||
|
}
|
||||||
|
if (segments.length === 0 && content.length > 0) {
|
||||||
|
return [{ type: 'text', text: content }]
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ContentPart 图片处理 ──
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
function imageSrc(img: ImageContentPart): string {
|
||||||
|
if (img.base64) {
|
||||||
|
const mt = img.media_type || 'image/png'
|
||||||
|
return `data:${mt};base64,${img.base64}`
|
||||||
|
}
|
||||||
|
return img.url || ''
|
||||||
|
}
|
||||||
|
function imgAlt(img: ImageContentPart): string {
|
||||||
|
return img.alt || t('aiChat.imagePreviewAlt')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="ai-msg"
|
||||||
|
:class="'ai-msg--' + msg.role"
|
||||||
|
:data-msg-id="msg.id"
|
||||||
|
>
|
||||||
|
<!-- 系统消息:压缩摘要 -->
|
||||||
|
<div v-if="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(msg)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户消息 -->
|
||||||
|
<div v-else-if="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">
|
||||||
|
<template v-for="(seg, idx) in segmentUserContent(msg)" :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 v-if="msgImageParts(msg).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" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
|
||||||
|
<svg width="12" height="12" 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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 助理消息 -->
|
||||||
|
<div v-else-if="msg.role === 'assistant'" class="ai-msg-ai">
|
||||||
|
<div class="ai-msg-avatar ai-msg-avatar--ai">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="ai-msg-bubble ai-msg-bubble--ai ai-md">
|
||||||
|
<!-- 流式分块渲染(末条 assistant 生成中) -->
|
||||||
|
<template v-if="isLastAi && streamingBlocks && streamingBlocks.length">
|
||||||
|
<span v-for="(block, _bi) in streamingBlocks" :key="block.key" v-html="block.html"></span>
|
||||||
|
<span v-if="isViewingGenerating" class="ai-cursor-blink">▊</span>
|
||||||
|
</template>
|
||||||
|
<!-- 完成/历史消息:整段 Markdown 渲染 -->
|
||||||
|
<template v-else>
|
||||||
|
<span v-html="renderContent(msg)"></span>
|
||||||
|
</template>
|
||||||
|
<!-- Token 用量 -->
|
||||||
|
<span v-if="msg.model" class="ai-msg-model-badge" :title="'Model: ' + msg.model">
|
||||||
|
{{ msg.model }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 操作按钮:复制/重新生成/编辑 -->
|
||||||
|
<div class="ai-msg-actions">
|
||||||
|
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||||||
|
</button>
|
||||||
|
<button v-if="isLastAi" class="ai-regen-btn" :title="t('aiChat.regenerate')" @click.stop="emit('regenerate', msg)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误消息 -->
|
||||||
|
<div v-else-if="msg.isError" class="ai-msg-ai ai-msg--error">
|
||||||
|
<div class="ai-msg-avatar ai-msg-avatar--ai">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--df-danger)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="ai-msg-bubble ai-msg-bubble--ai ai-msg-bubble--error">
|
||||||
|
<span>{{ msg.content }}</span>
|
||||||
|
<button class="ai-retry-btn" :title="t('aiChat.retry')" @click.stop="emit('retry', msg)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -19,9 +19,11 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { useMarkdown } from '../../composables/useMarkdown'
|
import { useMarkdown } from '../../composables/useMarkdown'
|
||||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||||
|
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||||
import ToolCardList from '../ToolCardList.vue'
|
import ToolCardList from '../ToolCardList.vue'
|
||||||
|
import MessageItem from './MessageItem.vue'
|
||||||
import { formatRelative, formatDate } from '../../utils/time'
|
import { formatRelative, formatDate } from '../../utils/time'
|
||||||
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
import type { AiMessage } from '../../api/types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
|
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
|
||||||
@@ -198,7 +200,7 @@ function scheduleStreamParse(text: string) {
|
|||||||
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
|
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
|
||||||
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
|
// 治 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)
|
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: 空状态示例问题 ──
|
// ── UX-2025-20: 空状态示例问题 ──
|
||||||
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
|
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
|
||||||
const examplePrompts = [
|
const examplePrompts = [
|
||||||
@@ -325,91 +271,20 @@ function isLastUser(msg: AiMessage): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ContentPart 图片片类型(discriminator type==='image' 的联合成员) */
|
|
||||||
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
|
|
||||||
|
|
||||||
/** 取消息的图片 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 → <img src>: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) */
|
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存) ──
|
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 useMessageScroll) ──
|
||||||
// 用户是否已在底部附近(上滑查看历史时不强制拉回)
|
const {
|
||||||
function isNearBottom(): boolean {
|
showBackToBottom,
|
||||||
const el = messagesContainer.value
|
isFollowingBottom,
|
||||||
if (!el) return true
|
onMessagesScroll,
|
||||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80
|
scrollToBottom,
|
||||||
}
|
onContentChange,
|
||||||
|
setFollowing,
|
||||||
// 回到底部按钮:内容溢出且不在底部时显示
|
} = useMessageScroll(messagesContainer)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
watch(() => store.state.messages.length, onContentChange)
|
watch(() => store.state.messages.length, onContentChange)
|
||||||
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
|
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
|
||||||
|
|
||||||
@@ -840,10 +715,8 @@ const renderItems = computed<RenderItem[]>(() => {
|
|||||||
|
|
||||||
// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。
|
// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。
|
||||||
watch(() => store.state.activeConversationId, () => {
|
watch(() => store.state.activeConversationId, () => {
|
||||||
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists
|
// SW-260618-18: 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
|
||||||
wasNearBottom = true
|
setFollowing(true)
|
||||||
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
|
|
||||||
isFollowingBottom = true
|
|
||||||
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
|
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
|
||||||
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
|
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
|
||||||
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
|
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
|
||||||
@@ -875,7 +748,7 @@ defineExpose({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll">
|
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
|
||||||
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) -->
|
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) -->
|
||||||
<div class="ai-empty" v-if="store.state.messages.length === 0 && !store.state.streaming">
|
<div class="ai-empty" v-if="store.state.messages.length === 0 && !store.state.streaming">
|
||||||
<template v-if="store.state.providers.length === 0">
|
<template v-if="store.state.providers.length === 0">
|
||||||
@@ -959,56 +832,17 @@ defineExpose({
|
|||||||
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
|
<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 class="ai-msg-system-summary ai-md" v-html="renderContent(item.msg)"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 用户消息 -->
|
<!-- 用户消息(提取至 MessageItem.vue) -->
|
||||||
<div v-else-if="item.msg.role === 'user'" class="ai-msg-user">
|
<MessageItem
|
||||||
<div class="ai-msg-avatar ai-msg-avatar--user">
|
v-else-if="item.msg.role === '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>
|
:msg="item.msg"
|
||||||
</div>
|
:is-last-ai="isLastAi(item.msg)"
|
||||||
<div class="ai-msg-bubble ai-msg-bubble--user">
|
:is-last-user="isLastUser(item.msg)"
|
||||||
<!-- Input Augmentation: 按 mention 区间切段渲染(chip 精确替换 [类型: 名] 标记,弃正则)。
|
:editing-msg-id="editingMsgId"
|
||||||
无 mentionSpans 时 segmentUserContent 返回单段 text(零回归,等同原 {{ content }})。 -->
|
:is-viewing-generating="isViewingGenerating"
|
||||||
<template v-for="(seg, i) in segmentUserContent(item.msg)" :key="(item.msg.id) + '-seg-' + i">
|
@copy="copyMsgContent"
|
||||||
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
|
@start-edit="(m) => emit('start-edit', m)"
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="ai-msg-chip"
|
|
||||||
:class="'ai-msg-chip--' + seg.span.kind"
|
|
||||||
>{{ seg.span.label }}</span>
|
|
||||||
</template>
|
|
||||||
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载;
|
|
||||||
B-260618-01 虚拟滚动已移除,气泡内 <img> 随消息恒渲染无裁剪) -->
|
|
||||||
<template v-if="msgImageParts(item.msg).length">
|
|
||||||
<div class="ai-msg-images">
|
|
||||||
<img
|
|
||||||
v-for="(img, i) in msgImageParts(item.msg)"
|
|
||||||
:key="(item.msg.id) + '-img-' + i"
|
|
||||||
:src="imageSrc(img)"
|
|
||||||
:alt="imgAlt(img)"
|
|
||||||
class="ai-msg-image"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<button
|
|
||||||
class="ai-copy-btn"
|
|
||||||
:title="$t('aiChat.copyMsg')"
|
|
||||||
@click.stop="copyMsgContent(item.msg)"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" 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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- UX-09:末条 user 消息编辑按钮(非生成中、非当前编辑态)hover 显示 -->
|
|
||||||
<button
|
|
||||||
v-if="isLastUser(item.msg) && !store.state.streaming && !editingMsgId"
|
|
||||||
class="ai-msg-edit-btn"
|
|
||||||
:title="$t('aiChat.editMessage')"
|
|
||||||
@click.stop="emit('start-edit', item.msg)"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间) -->
|
|
||||||
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelative(item.msg.timestamp) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 消息 -->
|
<!-- AI 消息 -->
|
||||||
<div v-else class="ai-msg-ai">
|
<div v-else class="ai-msg-ai">
|
||||||
|
|||||||
89
src/composables/ai/useMessageScroll.ts
Normal file
89
src/composables/ai/useMessageScroll.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! 消息列表滚动控制 — 跟随/锁存/回底按钮
|
||||||
|
//!
|
||||||
|
//! 职责:悬浮底部按钮显隐、跟随意图锁存(isFollowingBottom)、
|
||||||
|
//! scrollToBottom 回底操作、onContentChange 内容变化滚动策略。
|
||||||
|
//!
|
||||||
|
//! 不涉及工具卡片折叠逻辑(父组件注入 onNearBottom 回调解耦)。
|
||||||
|
|
||||||
|
import { ref, nextTick, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/** 底部阈值:距底部 <80px 视为"在底部" */
|
||||||
|
const NEAR_BOTTOM_PX = 80
|
||||||
|
/** 内容溢出阈值:scrollHeight - clientHeight >100px 认为内容溢出容器 */
|
||||||
|
const OVERFLOW_PX = 100
|
||||||
|
|
||||||
|
export function useMessageScroll(containerRef: Ref<HTMLDivElement | undefined>) {
|
||||||
|
// 回到底部按钮:内容溢出且不在底部时显示
|
||||||
|
const showBackToBottom = ref(false)
|
||||||
|
|
||||||
|
// B-260618-24: 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。
|
||||||
|
const isFollowingBottom = ref(true)
|
||||||
|
// 边沿检测:上一帧是否在底部(用于触发 onNearBottom 回调)
|
||||||
|
let wasNearBottom = true
|
||||||
|
|
||||||
|
/** 用户是否在底部附近(上滑查看历史时不强制拉回) */
|
||||||
|
function isNearBottom(): boolean {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return true
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新回到底部按钮状态 */
|
||||||
|
function refreshBackToBottom(): void {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) { showBackToBottom.value = false; return }
|
||||||
|
const overflow = el.scrollHeight - el.clientHeight > OVERFLOW_PX
|
||||||
|
showBackToBottom.value = overflow && !isNearBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动事件处理:刷新回底按钮 + 底部边沿检测触发回调。
|
||||||
|
* @param onNearBottom 滚近底部时的回调(父组件传入,如收起工具卡片)
|
||||||
|
*/
|
||||||
|
function onMessagesScroll(onNearBottom?: () => void): void {
|
||||||
|
refreshBackToBottom()
|
||||||
|
const near = isNearBottom()
|
||||||
|
if (near && !wasNearBottom) {
|
||||||
|
onNearBottom?.()
|
||||||
|
}
|
||||||
|
wasNearBottom = near
|
||||||
|
isFollowingBottom.value = near
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚动到容器底部 */
|
||||||
|
function scrollToBottom(smooth = false): void {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
isFollowingBottom.value = true // 显式回底 = 跟随意图
|
||||||
|
el.scrollTo({
|
||||||
|
top: el.scrollHeight,
|
||||||
|
behavior: smooth ? 'smooth' : 'instant',
|
||||||
|
})
|
||||||
|
showBackToBottom.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息/流式内容变化时:跟随意图则自动滚,否则只刷新回底按钮 */
|
||||||
|
function onContentChange(): void {
|
||||||
|
if (isFollowingBottom.value) {
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
} else {
|
||||||
|
refreshBackToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显式标记跟随意图(发新消息/切换对话时调用) */
|
||||||
|
function setFollowing(v: boolean): void {
|
||||||
|
isFollowingBottom.value = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showBackToBottom,
|
||||||
|
isFollowingBottom,
|
||||||
|
isNearBottom,
|
||||||
|
refreshBackToBottom,
|
||||||
|
onMessagesScroll,
|
||||||
|
scrollToBottom,
|
||||||
|
onContentChange,
|
||||||
|
setFollowing,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user