diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue
index 374161b..d735624 100644
--- a/src/components/AiChat.vue
+++ b/src/components/AiChat.vue
@@ -221,9 +221,10 @@ const showAgenticProgress = computed(() =>
// 进度文案:round 必显;pending>0 时附加待审批数(max/completed 本轮降级省略,见模板 TODO)
const agenticProgressText = computed(() => {
const pending = store.state.pendingApprovals.length
+ const completed = store.state.completedTools
return pending > 0
? t('ai.agenticProgress', { round: store.state.agentRound, pending })
- : t('ai.agenticProgressNoPending', { round: store.state.agentRound })
+ : t('ai.agenticProgressNoPending', { round: store.state.agentRound, completed })
})
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue
index 7e22a4a..03eb863 100644
--- a/src/components/ai/ChatInput.vue
+++ b/src/components/ai/ChatInput.vue
@@ -29,31 +29,17 @@
@drop="onDrop"
>
-
-
-
- {{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
-
-
-
- {{ g.label }}
-
-
- {{ item.typeLabel }}
- {{ item.name }}
- {{ item.desc }}
-
-
-
-
-
+
+
-
-
-
-
-
-
{{ $t('aiChat.enrichmentLabel') }}
-
{{ enrichmentDetailText }}
-
-
+
+
{{ $t('aiChat.inputHint') }}
@@ -130,7 +105,10 @@ import { useProjectStore } from '../../stores/project'
import { getConvState } from '../../composables/ai/useAiEvents'
import SkillMention from './SkillMention.vue'
import ImageInput from './ImageInput.vue'
+import MentionPopover from './MentionPopover.vue'
+import EnrichmentPanel from './EnrichmentPanel.vue'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
+import type { MentionItem } from './MentionItem'
const props = defineProps<{
/** UX-09: 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */
@@ -354,16 +332,7 @@ function clearSkill() {
// 联想源只读 projectsStore.projects / projectsStore.tasks(前端 store 现有数据,不新增 IPC)。
// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。
// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。
-interface MentionItem {
- type: 'project' | 'task' | 'idea'
- /** 扁平后唯一 key(用于 key/导航扁平索引定位) */
- uid: string
- /** 实体原始 id(供后端解析,目前仅插入 [类型:名] 文本,id 未透传) */
- id: string
- name: string
- desc: string
- typeLabel: string
-}
+// MentionItem 接口已抽至 ./MentionItem.ts(MentionPopover 子组件共用)。
const mentionOpen = ref(false)
const mentionIndex = ref(0)
@@ -962,44 +931,7 @@ defineExpose({
}
.ai-skill-empty { padding: 8px 12px; color: var(--df-text-dim); opacity: 0.7; font-size: 12px; }
-/* ── UX-10 §1.4: @ 实体引用浮层(复用 .ai-skill-popover 容器,仅新增组标题/类型标签) ── */
-.ai-mention-popover { /* 同 .ai-skill-popover 定位,这里仅为语义占位,样式继承 */ }
-.ai-mention-group {
- padding: 4px 10px 2px;
- font-size: 10px;
- font-weight: 600;
- color: var(--df-text-dim);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- user-select: none;
-}
-.ai-mention-item-name { color: var(--df-text); font-weight: 500; flex-shrink: 0; }
-.ai-mention-item-desc {
- color: var(--df-text-dim);
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-/* 类型标签小色块(项目/任务区分) */
-.ai-mention-item-type {
- flex-shrink: 0;
- padding: 1px 5px;
- border-radius: var(--df-radius-sm);
- font-size: 9px;
- font-weight: 600;
- letter-spacing: 0.3px;
- background: var(--df-bg-hover);
- color: var(--df-text-dim);
-}
-.ai-mention-item-type--project {
- background: color-mix(in srgb, var(--df-accent) 16%, transparent);
- color: var(--df-accent);
-}
-.ai-mention-item-type--task {
- background: color-mix(in srgb, var(--df-success) 16%, transparent);
- color: var(--df-success);
-}
+/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
.ai-input {
flex: 1;
@@ -1071,85 +1003,5 @@ defineExpose({
opacity: 0.6;
}
-/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
-.ai-enrichment-bar {
- display: flex;
- align-items: center;
- margin-top: 6px;
- border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
- border-radius: var(--df-radius);
- overflow: hidden;
-}
-.ai-enrichment-toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- width: 100%;
- padding: 4px 8px;
- border: none;
- background: color-mix(in srgb, var(--df-accent) 10%, transparent);
- color: var(--df-text);
- cursor: pointer;
- font-size: 11px;
- text-align: left;
- transition: background 0.15s;
-}
-.ai-enrichment-toggle:hover {
- background: color-mix(in srgb, var(--df-accent) 18%, transparent);
-}
-.ai-enrichment-toggle--expanded {
- border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
-}
-.ai-enrichment-chevron {
- flex-shrink: 0;
- color: var(--df-text-dim);
- transition: transform 0.15s;
-}
-.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
- transform: rotate(180deg);
-}
-.ai-enrichment-dismiss {
- flex-shrink: 0;
- border: none;
- background: transparent;
- color: var(--df-text-dim);
- cursor: pointer;
- font-size: 14px;
- padding: 0 4px;
- line-height: 1;
- transition: color 0.15s;
-}
-.ai-enrichment-dismiss:hover {
- color: var(--df-danger);
-}
-.ai-enrichment-badge {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--df-accent);
- font-weight: 500;
-}
-.ai-enrichment-panel {
- padding: 6px 8px;
- background: var(--df-bg);
- max-height: 200px;
- overflow-y: auto;
-}
-.ai-enrichment-label {
- font-size: 10px;
- color: var(--df-text-dim);
- margin-bottom: 4px;
- font-weight: 600;
-}
-.ai-enrichment-body {
- margin: 0;
- font-size: 11px;
- line-height: 1.6;
- color: var(--df-text);
- white-space: pre-wrap;
- word-break: break-all;
- font-family: var(--df-font-mono, ui-monospace, monospace);
-}
+/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
diff --git a/src/components/ai/EmptyState.vue b/src/components/ai/EmptyState.vue
new file mode 100644
index 0000000..60476ef
--- /dev/null
+++ b/src/components/ai/EmptyState.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+ {{ $t('aiChat.emptyNoProvider') }}
+ {{ $t('aiChat.emptyNoProviderHint') }}
+
+
+
+
+
+ {{ $t('aiChat.emptyTitle') }}
+ {{ $t('aiChat.emptyHint') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ai/EnrichmentPanel.vue b/src/components/ai/EnrichmentPanel.vue
new file mode 100644
index 0000000..47cd3ee
--- /dev/null
+++ b/src/components/ai/EnrichmentPanel.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
{{ $t('aiChat.enrichmentLabel') }}
+
{{ enrichmentDetailText }}
+
+
+
+
+
diff --git a/src/components/ai/MentionItem.ts b/src/components/ai/MentionItem.ts
new file mode 100644
index 0000000..4485d50
--- /dev/null
+++ b/src/components/ai/MentionItem.ts
@@ -0,0 +1,15 @@
+//! @ 实体引用相关共享类型 — ChatInput / MentionPopover 共用。
+//!
+//! 抽自 ChatInput.vue(零行为变更),供 MentionPopover.vue 子组件复用。
+
+/** @ 联想候选实体(项目/任务/灵感,扁平后唯一 uid 供 key/导航) */
+export interface MentionItem {
+ type: 'project' | 'task' | 'idea'
+ /** 扁平后唯一 key(用于 key/导航扁平索引定位) */
+ uid: string
+ /** 实体原始 id(供后端解析,目前仅插入 [类型:名] 文本,id 未透传) */
+ id: string
+ name: string
+ desc: string
+ typeLabel: string
+}
diff --git a/src/components/ai/MentionPopover.vue b/src/components/ai/MentionPopover.vue
new file mode 100644
index 0000000..9631f78
--- /dev/null
+++ b/src/components/ai/MentionPopover.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+ {{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
+
+
+
+ {{ g.label }}
+
+
+ {{ item.typeLabel }}
+ {{ item.name }}
+ {{ item.desc }}
+
+
+
+
+
+
+
+
diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue
index 2395bf1..0a3d322 100644
--- a/src/components/ai/MessageList.vue
+++ b/src/components/ai/MessageList.vue
@@ -18,10 +18,11 @@ import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
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 { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
+import EmptyState from './EmptyState.vue'
import { formatRelative, formatDate } from '../../utils/time'
import type { AiMessage } from '../../api/types'
@@ -43,183 +44,16 @@ const { t } = useI18n()
// ═══ Markdown 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══
// B-23:渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
// renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。
-// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)保留在本组件,
-// 经 getMarked()/getPurify()/mdReady/escapeFallback 复用同一份渲染器实例,行为零变化。
-const {
- mdReady,
- loadMarkdown,
- renderMd,
- escapeFallback,
- getMarked,
- getPurify,
-} = useMarkdown()
+// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)抽至
+// composables/ai/useStreamRenderer(本组件经 opts 注入 saveSelection/restoreSelection/
+// isFollowingBottom/scrollToBottom 回调),行为零变化。
+//
+// useStreamRenderer 的初始化延后到 saveSelection/restoreSelection 定义之后(下方),
+// 因 composable 的 opts 依赖这两个函数。mdReady/renderMd/escapeFallback/streamingBlocks/
+// scheduleStreamParse/renderStreamingBlocks/clearStreamingState 均由 composable 返回。
+const { loadMarkdown } = useMarkdown()
-// ═══ AR-1 流式 Markdown 渲染开关(保守方案 B 的可回退开关) ═══
-// appSettings key `df-ai-streaming-md`,默认 true(开):流式走块级 memo + rAF 节流的
-// Markdown 分块渲染(代码块/列表/标题流式过程中就有格式)。关(false):回退纯文本短路
-// (escapeFallback,每 delta 全文转义,
换行)——即 AR-1 修复前的原行为,供性能敏感/
-// 极端掉帧场景降级。读走 appSettings 缓存(响应式,Settings 改动即时生效)。
-const appSettings = useAppSettingsStore()
-const streamingMdEnabled = computed(() => appSettings.get('df-ai-streaming-md', true))
-// _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null);
-// 流式 parse 经 getter 取最新引用,行为零变化。
-// 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2)
-const _blockCache = new Map()
-const BLOCK_CACHE_LIMIT = 300
-// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
-// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持(UX-2025-01)
-const streamingBlocks = ref([])
-let rafId: number | null = null
-let lastStreamText = ''
-
-/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块
-/// (跨双换行不切),非代码段按双换行切段。
-/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
-///
-/// CR-260615-04:旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则
-/// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错,
-/// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏
-/// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏),
-/// 段落/标题等其余 token 的 .raw 按 \n\n 切段。lexer 与 parse 用同一份 marked 实例 +
-/// 同一份围栏规则,切块边界与渲染边界一致。
-function splitBlocks(text: string): string[] {
- const marked = getMarked()
- const blocks: string[] = []
- // marked 未就绪时 splitBlocks 不应被调用(renderStreamingBlocks 已 mdReady 守卫),
- // 此处兜底返回原文,不引入正则。
- if (!marked) return [text]
- const tokens = marked.lexer(text)
- for (const tok of tokens) {
- if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue
- const raw = (tok as { raw: string }).raw
- if ((tok as { type: string }).type === 'code') {
- // 代码围栏 token:整段一块(含行首可选空格 + 3+ 反引号 + info 字符串)
- if (raw.trim()) blocks.push(raw)
- continue
- }
- // 非代码 token(paragraph/space/heading/list/blockquote/...):
- // 按 \n\n 切段(段落内单换行不切,inline code 自然保留在段内)
- for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b)
- }
- return blocks.length ? blocks : [text]
-}
-
-/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
-function parseBlockNoCache(block: string): string {
- // _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。
- // UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
- // getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的
- // renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse()
- // 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback,
- // parse 抛错时 try/catch 同样降级,保证流式不中断。
- const marked = getMarked()
- const purify = getPurify()
- if (!marked || !purify) return escapeFallback(block)
- try {
- return purify.sanitize(marked.parse(block) as string)
- } catch {
- return escapeFallback(block)
- }
-}
-
-/// 块级 memo parse:已完成块缓存命中,O(末块)
-function parseBlock(block: string): string {
- const cached = _blockCache.get(block)
- if (cached !== undefined) return cached
- const html = parseBlockNoCache(block)
- if (_blockCache.size >= BLOCK_CACHE_LIMIT) _blockCache.delete(_blockCache.keys().next().value!)
- _blockCache.set(block, html)
- return html
-}
-
-/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
-/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
-/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
-interface StreamBlock {
- html: string
- key: string // 已完成块=block文本hash(稳定), 末块='tail-'+递增序号(每次变)
-}
-function renderStreamingBlocks(text: string): StreamBlock[] {
- if (!text) return []
- // AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 +
,原行为)
- // mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
- if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
- return [{ html: escapeFallback(text), key: 'fallback' }]
- }
- const blocks = splitBlocks(text)
- const n = blocks.length
- let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
- return blocks.map((b, i) => {
- const isTail = i === n - 1
- // AR-1 代码块降级:末块若是未闭合的代码围栏(```/~~~ 数为奇数,说明代码块还没结束),
- // 不走 marked.parse(未闭合围栏 → marked 推断为代码块 + hljs 对不完整代码高亮,
- // 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
- // 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
- const degrade = isTail && isUnclosedCodeFence(b)
- const html = degrade
- ? escapeFallback(b)
- : isTail ? parseBlockNoCache(b) : parseBlock(b)
- // 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
- const key = isTail ? `tail-${++tailSeq}` : `b-${simpleHash(b)}`
- return { html, key }
- })
-}
-
-/// 末块未闭合代码围栏检测:统计行首(可选 ≤3 空格)的 ``` / ~~~ 围栏开/闭数量,
-/// 奇数 = 有未闭合的代码块(流式还在写入该代码块)。仅末块调用,前块必已闭合(splitBlocks
-/// 已把完整 code token 切成独立块)。对齐 marked 围栏规则(行首 ≤3 空格 + 3+ 反引号/波浪)。
-function isUnclosedCodeFence(block: string): boolean {
- let fenceCount = 0
- const lines = block.split('\n')
- for (const line of lines) {
- // 行首 ≤3 空格 + 3+ 反引号或波浪(marked 围栏开关判定)
- const m = /^[ ]{0,3}(`{3,}|~{3,})/.exec(line)
- if (m) fenceCount++
- }
- return fenceCount % 2 === 1
-}
-
-/// 轻量字符串哈希:用于 block key 稳定性(非加密,仅避免长文本做 key)
-function simpleHash(s: string): number {
- let h = 5381
- for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
- return h >>> 0
-}
-
-/// rAF 节流:同帧多 delta 仅排队一次,帧内取最新 lastStreamText parse
-function scheduleStreamParse(text: string) {
- lastStreamText = text
- if (rafId !== null) return
- rafId = requestAnimationFrame(() => {
- rafId = null
- // UX-2025-01: 末块 innerHTML 每帧替换,保存选区
- saveSelection()
- streamingBlocks.value = renderStreamingBlocks(lastStreamText)
- nextTick(() => {
- restoreSelection()
- // B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
- // 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
- // 滚到渲染前高度)致"差一截"。
- if (isFollowingBottom.value) scrollToBottom()
- })
- })
-}
-
-/// template 绑定入口:最后AI消息流式时用 streamingBlocks(分块v-for,已完成块DOM稳定),
-/// 否则 renderMd(完成/历史,整段string给v-html)
-function renderContent(msg: AiMessage): string {
- // 流式:由模板直接读 streamingBlocks(v-for 分块),此处不参与
- return renderMd(msg.content)
-}
-
-// ── UX-2025-20: 空状态示例问题 ──
-// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
-const examplePrompts = [
- 'aiChat.examplePrompt1',
- 'aiChat.examplePrompt2',
- 'aiChat.examplePrompt3',
- 'aiChat.examplePrompt4',
-] as const
+// ── UX-2025-20: 空状态示例问题(examplePrompts 已随 EmptyState.vue 提取) ──
const messagesContainer = ref()
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
@@ -285,6 +119,24 @@ const {
onContentChange,
setFollowing,
} = useMessageScroll(messagesContainer)
+
+// ═══ 流式 Markdown 块级 memo 渲染(抽至 composables/ai/useStreamRenderer) ═══
+// opts 注入 saveSelection/restoreSelection(function 声明,提升可用,定义在下方)、
+// isFollowingBottom/scrollToBottom(来自上方 useMessageScroll)。
+const {
+ mdReady,
+ renderMd,
+ streamingBlocks,
+ scheduleStreamParse,
+ clearStreamingState,
+ cancelPendingRaf,
+} = useStreamRenderer({
+ saveSelection,
+ restoreSelection,
+ isFollowingBottom,
+ scrollToBottom,
+})
+
watch(() => store.state.messages.length, onContentChange)
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
@@ -308,8 +160,7 @@ watch(() => store.state.currentText, (text) => {
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
if (!text) {
- streamingBlocks.value = []
- lastStreamText = ''
+ clearStreamingState()
}
onContentChange()
}
@@ -320,7 +171,9 @@ watch(mdReady, (ready) => {
}
})
watch(() => store.state.streaming, (s) => {
- if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
+ // 原行为:两分支均先取消 pending rAF(streaming 中途切换帧防泄漏);s===true 不清
+ // streamingBlocks(依赖 currentText watch 的 !text 分支按新轮时机清,BUG-260624-01)。
+ cancelPendingRaf()
if (s) {
// UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
@@ -332,8 +185,7 @@ watch(() => store.state.streaming, (s) => {
} else {
// UX-2025-01: 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建
saveSelection()
- streamingBlocks.value = []
- lastStreamText = ''
+ clearStreamingState()
nextTick(() => restoreSelection())
}
})
@@ -724,16 +576,16 @@ watch(() => store.state.activeConversationId, () => {
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
if (streamingBlocks.value.length) {
- streamingBlocks.value = []
- lastStreamText = ''
+ clearStreamingState()
+ } else {
+ cancelPendingRaf()
}
- if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
onBeforeUnmount(() => {
- if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
+ cancelPendingRaf()
})
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
@@ -749,37 +601,13 @@ defineExpose({
collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
-
-
-
-
-
-
- {{ $t('aiChat.emptyNoProvider') }}
- {{ $t('aiChat.emptyNoProviderHint') }}
-
-
-
-
-
- {{ $t('aiChat.emptyTitle') }}
- {{ $t('aiChat.emptyHint') }}
-
-
-
-
-
-
+
+
emit('send-example-prompt', i18nKey)"
+ @go-to-settings="emit('go-to-settings')"
+ />
{{ $t('aiChat.compressedSummaryLabel') }}
-
+
-
+