重构: 前端 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:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user