重构: 前端 composable 拆分+详情页 Markdown 渲染+流式 memo+状态同步+bug 修复

- composables/useMarkdown 抽(AiChat 复用)+ai/* 流式核心+useAiSend catch 回滚 user msg(B-21)+useAiStream 超时文案(B-03)+useAiConversations 切换 token(FR-R1)
- views/TaskDetail 新建(F-02)+描述 md+mdReady 响应式+字段同行布局(B-24/31)
- views/ProjectDetail 多选审批 UI(F-01)+描述 md+mdReady+字段同行(B-25/31)
- views/Ideas+Knowledge 描述 md+mdReady(B-25)
- views/Tasks 项目切换联动(B-29)+task 详情跳转
- components/AiChat toast 失败提示(B-20)+流式块级 memo(ARC-08)+clean UI(AR-7)+stop 兜底(AR-5)
- components/ToolCard 审批卡片可读化(AR-3)
- stores useAiStore barrel(ARC-04)+del settings mock(ARC-01)+approve options(R-PD-5)
- i18n aiTool/nav 中英对称(AR-3/9)+删 nav.decisions 死链(ARC-02)
- router /tasks/:id(F-02)+global.css selection 可见(R-260615-01)+api task detail+App 删 decisions nav
This commit is contained in:
2026-06-15 05:14:56 +08:00
parent 2de0c6ecb7
commit 80b9243528
26 changed files with 997 additions and 207 deletions

View File

@@ -197,7 +197,7 @@
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': msg.isError }"
>
<div v-html="renderMd(isLastAi(msg) && store.state.currentText ? store.state.currentText : msg.content, isLastAi(msg) && store.state.streaming)"></div>
<div v-html="renderContent(msg)"></div>
<span v-if="isLastAi(msg) && isViewingGenerating" class="ai-cursor"></span>
</div>
<!-- 流式开始但尚无文本时显示光标 -->
@@ -308,14 +308,20 @@
</div>
<!-- 确认弹层删对话等不可逆操作 -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @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>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, onMounted, watch } from 'vue'
import { ref, reactive, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../stores/ai'
import ConfirmDialog from './ConfirmDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useMarkdown } from '../composables/useMarkdown'
import ToolCardList from './ToolCardList.vue'
import { formatRelativeZh } from '../utils/time'
import type { AiConversationSummary, AiMessage, SkillInfo } from '../api/types'
@@ -324,43 +330,112 @@ const props = defineProps<{
detached?: boolean
}>()
// marked + DOMPurify 体积较大,延迟加载以加快首屏;加载完成后 mdReady 翻转触发重渲染
let _marked: typeof import('marked')['marked'] | null = null
let _purify: typeof import('dompurify')['default'] | null = null
const mdReady = ref(false)
// markdown 渲染结果缓存:历史消息文本不变,重渲染(折叠/状态变化触发)命中缓存省去 marked+sanitize 全量解析
const _mdCache = new Map<string, string>()
// ═══ Markdown 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══
// B-23:渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
// renderMd)抽至 composables/useMarkdown(模块级单例),本组件与 TaskDetail 共享。
// 流式核心(splitBlocks/parseBlock/renderStreamingMd/scheduleStreamParse)保留在本组件,
// 经 getMarked()/getPurify()/mdReady/escapeFallback 复用同一份渲染器实例,行为零变化。
const {
mdReady,
loadMarkdown,
renderMd,
escapeFallback,
getMarked,
getPurify,
} = useMarkdown()
// _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null);
// 流式 parse 经 getter 取最新引用,行为零变化。
// 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2)
const _blockCache = new Map<string, string>()
const BLOCK_CACHE_LIMIT = 300
// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
const streamingHtml = ref('')
let rafId: number | null = null
let lastStreamText = ''
async function loadMarkdown() {
const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')])
marked.setOptions({ breaks: true, gfm: true })
_marked = marked
_purify = dp.default
_mdCache.clear() // 渲染器就绪,清掉之前 escapeHtml 兜底缓存,触发完整 markdown 重渲染
mdReady.value = true
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function renderMd(text: string, isStreaming = false): string {
if (!text) return ''
// 流式态纯文本(AR-1):避免每 delta 全量 marked.parse+sanitize 致主线程阻塞掉帧;
// 不缓存流式文本(每 delta 变,缓存无收益且污染),仅 escapeHtml + 换行转 <br>;完成后再走 markdown
if (isStreaming || !mdReady.value || !_marked || !_purify) {
return escapeHtml(text).replace(/\n/g, '<br>')
/// 切块:代码围栏(```...```)整体一块(跨双换行不切),非代码段按双换行切段。
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
function splitBlocks(text: string): string[] {
const blocks: string[] = []
const fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g
let last = 0
let m: RegExpExecArray | null
while ((m = fenceRe.exec(text)) !== null) {
if (m.index > last) {
for (const b of text.slice(last, m.index).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
}
blocks.push(m[0])
last = m.index + m[0].length
}
const cached = _mdCache.get(text)
if (last < text.length) {
for (const b of text.slice(last).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 后被流式分支调用
const marked = getMarked()
const purify = getPurify()
return purify!.sanitize(marked!.parse(block) as string)
}
/// 块级 memo parse:已完成块缓存命中,O(末块)
function parseBlock(block: string): string {
const cached = _blockCache.get(block)
if (cached !== undefined) return cached
const html = _purify.sanitize(_marked.parse(text) as string)
if (_mdCache.size > 200) _mdCache.clear()
_mdCache.set(text, html)
const html = parseBlockNoCache(block)
if (_blockCache.size >= BLOCK_CACHE_LIMIT) _blockCache.delete(_blockCache.keys().next().value!)
_blockCache.set(block, html)
return html
}
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
function renderStreamingMd(text: string): string {
if (!text) return ''
if (!mdReady.value || !getMarked() || !getPurify()) {
return escapeFallback(text)
}
const blocks = splitBlocks(text)
const n = blocks.length
return blocks.map((b, i) => (i === n - 1 ? parseBlockNoCache(b) : parseBlock(b))).join('')
}
/// rAF 节流:同帧多 delta 仅排队一次,帧内取最新 lastStreamText parse
function scheduleStreamParse(text: string) {
lastStreamText = text
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
streamingHtml.value = renderStreamingMd(lastStreamText)
})
}
/// template 绑定入口:最后AI消息流式时用 streamingHtml(rAF节流块级memo),否则 renderMd(完成/历史)
function renderContent(msg: AiMessage): string {
if (isLastAi(msg) && store.state.streaming && store.state.currentText) {
return streamingHtml.value
}
return renderMd(msg.content)
}
const store = useAiStore()
const { t } = useI18n()
// ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ──
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
const toast = reactive({ visible: false, msg: '', type: 'error' as 'error' | 'warning' | 'info' })
let _toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
toast.msg = msg
toast.type = type
toast.visible = true
if (_toastTimer) clearTimeout(_toastTimer)
_toastTimer = setTimeout(() => { toast.visible = false }, 3000)
}
const inputText = ref('')
const inputEl = ref<HTMLTextAreaElement>()
const messagesContainer = ref<HTMLDivElement>()
@@ -368,23 +443,9 @@ const messagesContainer = ref<HTMLDivElement>()
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
// ── 确认弹层(删对话等不可逆操作,复用 ConfirmDialog 组件) ──
const confirmState = reactive({
visible: false,
msg: '',
resolve: null as null | ((v: boolean) => void),
})
function confirmDialog(msg: string): Promise<boolean> {
return new Promise(resolve => {
confirmState.msg = msg
confirmState.visible = true
confirmState.resolve = resolve
})
}
function answerConfirm(ok: boolean) {
confirmState.visible = false
confirmState.resolve?.(ok)
confirmState.resolve = null
}
// 状态机抽至 composables/useConfirm(原 Projects/ProjectDetail/Ideas/Settings/AiChat 各重复一份)
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,绑定 ConfirmDialog 不变
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
async function confirmDeleteConversation(id: string) {
const conv = store.state.conversations.find(c => c.id === id)
const title = conv?.title || t('aiChat.newConversation')
@@ -609,6 +670,9 @@ async function handleSend() {
console.error('[AI] 发送失败:', e)
inputText.value = text
pendingSkill.value = skill
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
const msg = e instanceof Error ? e.message : String(e)
showToast(`发送失败:${msg}`, 'error')
}
await nextTick()
scrollToBottom()
@@ -651,6 +715,20 @@ function onContentChange() {
watch(() => store.state.messages.length, onContentChange)
watch(() => store.state.currentText, onContentChange)
// 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo):
// - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块
// - streaming 翻转:结束清 rAF + 清 streamingHtml(回 renderMd 走 final marked + 缓存)
watch(() => store.state.currentText, (text) => {
if (store.state.streaming && text) scheduleStreamParse(text)
})
watch(() => store.state.streaming, (s) => {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
if (!s) { streamingHtml.value = ''; lastStreamText = '' }
})
onBeforeUnmount(() => {
if (rafId !== null) cancelAnimationFrame(rafId)
})
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化/文本增长)时,
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
@@ -689,7 +767,7 @@ onMounted(async () => {
const t0 = (window as any).__APP_T0 ?? 0
// 启动耗时埋点:debug 级(浏览器默认折叠不干扰,空白屏复现时可开 verbose 看)
console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`)
loadMarkdown() // 后台预热 Markdown 渲染器不阻塞首屏
loadMarkdown() // 后台预热 Markdown 渲染器,不阻塞首屏
await store.startListener()
await store.loadProviders()
await store.loadConversations()
@@ -714,6 +792,7 @@ async function toggleAlwaysOnTop() {
<style scoped>
.ai-panel {
position: relative; /* 容纳 .ai-toast(absolute,相对面板定位,嵌入/分离模式均生效) */
display: flex;
flex-direction: row;
height: 100%;
@@ -1356,4 +1435,24 @@ async function toggleAlwaysOnTop() {
opacity: 0.6;
}
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */
.ai-toast {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: var(--df-radius);
font-size: 12px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 80%;
overflow-wrap: anywhere;
}
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
</style>

View File

@@ -320,13 +320,30 @@ function displayArgValue(arg: { key: string; val: unknown }): string {
return formatArgValue(arg.val)
}
/** 工具显示名称(含路径摘要);无 path 时硬编码 fallback(path 几乎总有,i18n 兜底属过度防御) */
/**
* 工具显示名称(含目标摘要);文件类按 path,项目类(delete/restore/purge/update_project)按 id
* 解析项目名(复用 projectNameById,无则走 fallback),create_* 用固定文案。
*/
function toolDisplayName(tc: AiToolCallInfo): string {
const p = (tc.args as any)?.path as string | undefined
const args = tc.args as any
const p = args?.path as string | undefined
switch (tc.name) {
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
case 'delete_project':
case 'restore_project':
case 'purge_project':
case 'update_project': {
const idKey = PROJECT_ID_TOOL_ARG[tc.name]
const id = idKey ? (args?.[idKey] as string | undefined) : ''
const name = id ? projectNameById.value[id] : ''
const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix'
const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback'
return name ? `${t('aiTool.' + prefixKey)}${name}` : t('aiTool.' + fallbackKey)
}
case 'create_task': return t('aiTool.createTask')
case 'create_project': return t('aiTool.createProject')
default: return formatToolName(tc.name)
}
}