diff --git a/tests/frontend/unit/b3/bench.store.test.js b/tests/frontend/unit/b3/bench.store.test.js index 715b65a..de61da0 100644 --- a/tests/frontend/unit/b3/bench.store.test.js +++ b/tests/frontend/unit/b3/bench.store.test.js @@ -164,10 +164,12 @@ describe('bench store', () => { }); await store.loadSessionSources(); expect(httpGet).toHaveBeenCalledWith('/api/sessions/sess-1/sources', undefined); - expect(store.sessionSourceItems).toHaveLength(2); // paper_id 去重 - const byPaper = store.sessionSourceItems.find((s) => s.paper_id === 'PMID1'); - expect(byPaper.count).toBe(3); // 累计出现次数 - expect(store.sources).toHaveLength(2); + // 缺陷 #27:前端不再二次聚合——后端去重结果原样透出 + expect(store.sources).toHaveLength(3); + expect(store.sources[0].src.paper_id).toBe('PMID1'); + expect(store.sources[0].label).toBe(2); // 后端给的第一条 count + expect(store.sources[1].label).toBe(1); + expect(store.sources[2].label).toBe(1); // 切回 current:仍为 null(无 B2) store.setSourceScope('current'); diff --git a/ui/src/components/chat/ChatPanel.vue b/ui/src/components/chat/ChatPanel.vue index b9ba936..823a0e3 100644 --- a/ui/src/components/chat/ChatPanel.vue +++ b/ui/src/components/chat/ChatPanel.vue @@ -54,6 +54,12 @@ const jumpToGraph = () => { const stop = () => chat.stop(); const retry = () => chat.retry(); const changeMode = (mode) => chat.setMode(mode); +// r3-07-01:揭示动画「跳过」——MessageList 转发后落 store.skipReveal()(立即定格全文) +const skipReveal = () => { + try { + chat.skipReveal(); + } catch { /* chat store 未就绪(测试/降级):静默 */ } +}; // 切换会话/发送后清空输入框无需处理(Composer 自持 draft,欢迎态切换即重置) @@ -94,6 +100,7 @@ const changeMode = (mode) => chat.setMode(mode); @retry="retry" @jump-graph="jumpToGraph" @jump-source="jumpToSource" + @skip-reveal="skipReveal" /> diff --git a/ui/src/components/chat/MessageList.vue b/ui/src/components/chat/MessageList.vue index 6541e16..b481dd7 100644 --- a/ui/src/components/chat/MessageList.vue +++ b/ui/src/components/chat/MessageList.vue @@ -14,7 +14,7 @@ const props = defineProps({ scrollSeq: { type: Number, default: 0 }, // chat store 的滚动请求序号 }); -const emit = defineEmits(['retry', 'jump-graph', 'jump-source']); +const emit = defineEmits(['retry', 'jump-graph', 'jump-source', 'skip-reveal']); const el = ref(null); const stickToBottom = ref(true); @@ -65,6 +65,7 @@ onBeforeUnmount(() => {}); @retry="emit('retry')" @jump-graph="emit('jump-graph')" @jump-source="(payload) => emit('jump-source', payload)" + @skip-reveal="emit('skip-reveal')" /> diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js index eb2faa7..489d97c 100644 --- a/ui/src/stores/chat.js +++ b/ui/src/stores/chat.js @@ -943,6 +943,10 @@ export const useChatStore = defineStore('chat', { pipe.status = 'done'; pipe.taskStatus = 'done'; pipe.steps.forEach((st) => { st.state = 'done'; }); + // r3-52-02:done 后还原 generation 步默认 desc(running 时被 + // 「生成中 x/y 节」覆盖,终态不得残留进度文案) + const genStep = pipe.steps.find((st) => st.phase === 'generation'); + if (genStep) genStep.desc = DOCGEN_STEPS.find((st) => st.phase === 'generation')?.desc || genStep.desc; if (artifact) { const art = { ...artifact }; const artId = art.artifact_id || art.id; diff --git a/ui/tests/r3debug/docgenPoll.test.js b/ui/tests/r3debug/docgenPoll.test.js index 621e6a1..a020f23 100644 --- a/ui/tests/r3debug/docgenPoll.test.js +++ b/ui/tests/r3debug/docgenPoll.test.js @@ -113,26 +113,48 @@ describe('#52 docgen 异步轮询状态机', () => { it('done:挂 artifact 块 + 落库 + adoptArtifact + 切文档 TAB', async () => { const { chat } = freshStores(); vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-3', status: 'queued' }); - vi.mocked(getTask).mockResolvedValue({ - ok: true, - task: { - id: 'task-3', status: 'done', - progress: [], - artifact: { artifact_id: 'art-1', title: '方案', template_name: '方案计划', template_key: 'plan', has_html: true, letterhead: false, file_name: 'f.docx' }, - error: null, - }, - }); + // r3-52-02 复现:先 running(generation desc 被「生成中 x/y 节」覆盖)→ 再 done + vi.mocked(getTask) + .mockResolvedValueOnce({ + ok: true, + task: { + id: 'task-3', status: 'running', + progress: [ + { section: 1, title: 'a', state: 'done' }, + { section: 2, title: 'b', state: 'running' }, + ], + artifact: null, error: null, + }, + }) + .mockResolvedValue({ + ok: true, + task: { + id: 'task-3', status: 'done', + progress: [], + artifact: { artifact_id: 'art-1', title: '方案', template_name: '方案计划', template_key: 'plan', has_html: true, letterhead: false, file_name: 'f.docx' }, + error: null, + }, + }); const p = chat.sendDocgen('写一份方案', []); await vi.advanceTimersByTimeAsync(0); await p; + // 第一 tick:running,desc 被进度文案覆盖 + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(0); + let aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); + expect(aMsg.blocks[0].steps.find((s) => s.phase === 'generation').desc).toContain('生成中 1/2'); + // 第二 tick:done,desc 必须还原默认文案(r3-52-02) await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0); // adoptArtifact 内部异步拉取 - const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); + aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); expect(aMsg.status).toBe('done'); expect(aMsg.blocks.some((b) => b.type === 'artifact' && b.artifact_id === 'art-1')).toBe(true); expect(aMsg.blocks[0].status).toBe('done'); expect(aMsg.blocks[0].steps.every((s) => s.state === 'done')).toBe(true); + const genStep = aMsg.blocks[0].steps.find((s) => s.phase === 'generation'); + expect(genStep.desc).toBe('按大纲逐节生成正文'); + expect(genStep.desc).not.toContain('生成中'); // 轮询注册自清(终态后不再有定时器) const { useDocgenStore } = await import('../../src/stores/docgen.js'); expect(useDocgenStore().activeArtifact?.artifact_id).toBe('art-1'); diff --git a/ui/tests/r3debug/skipReveal.test.js b/ui/tests/r3debug/skipReveal.test.js new file mode 100644 index 0000000..60a061e --- /dev/null +++ b/ui/tests/r3debug/skipReveal.test.js @@ -0,0 +1,151 @@ +// @vitest-environment jsdom +// tests/r3debug/skipReveal.test.js —— r3-07-01「跳过」按钮事件链全链路 +// 断言:AssistantMessage skip-btn 点击 → MessageList 转发 skip-reveal → +// ChatPanel 落 store.skipReveal() → 消息定格终态(stream 块换 text 块)。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; + +// jsdom 无 scrollTo:polyfill(MessageList 挂载滚动用) +if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) { + window.Element.prototype.scrollTo = vi.fn(); +} + +vi.mock('../../src/stores/health.js', () => ({ + useHealthStore: vi.fn(() => ({ data: null, qaOk: true })), +})); +vi.mock('../../src/api/sessions.js', () => ({ + listSessions: vi.fn(async () => []), + createSession: vi.fn(async () => ({ id: 's1' })), + getSession: vi.fn(async () => ({ id: 's1', status: 'done', meta: {}, messages: [] })), + updateSession: vi.fn(async (id, b) => ({ id, ...b })), + deleteSession: vi.fn(async () => {}), + createMessage: vi.fn(async () => ({ id: 'm1' })), + updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })), +})); +vi.mock('../../src/api/qa.js', () => ({ + chatStream: vi.fn(async () => {}), + chat: vi.fn(), + search: vi.fn(), + info: vi.fn(), +})); +vi.mock('../../src/api/private.js', () => ({ + askPrivate: vi.fn(async () => ({ hit: false })), +})); +vi.mock('../../src/api/docgen.js', () => ({ + generate: vi.fn(), + getTask: vi.fn(), + listTasks: vi.fn(), + getArtifactContent: vi.fn(async () => ({ markdown: '' })), + getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })), + saveArtifactContent: vi.fn(async () => ({})), + editArtifact: vi.fn(async () => ({})), + exportArtifact: vi.fn(async () => ({})), + uploadLibrary: vi.fn(), + listLibrary: vi.fn(async () => ({ items: [] })), + removeLibrary: vi.fn(async () => ({ ok: true })), +})); + +import ChatPanel from '../../src/components/chat/ChatPanel.vue'; +import MessageList from '../../src/components/chat/MessageList.vue'; +import AssistantMessage from '../../src/components/chat/AssistantMessage.vue'; +import { useChatStore } from '../../src/stores/chat.js'; +import { useSessionsStore } from '../../src/stores/sessions.js'; + +/** 构造揭示中的会话态:aMsg 带 stream 块 + ctx.reveal */ +function seedRevealing(chat) { + const msgList = [ + { id: 'u1', role: 'user', status: 'done', text: '问题', blocks: [{ type: 'text', html: '问题' }] }, + { + id: 'a1', + role: 'assistant', + kind: 'qa', + status: 'streaming', + streamErr: null, + revealId: 1, // revealText 正常流程会写入;skipReveal 据此定位目标消息 + blocks: [ + { type: 'pipeline', status: 'running', steps: [] }, + { type: 'stream', text: '部分内容' }, + ], + }, + ]; + chat.sessionId = 's1'; + chat.messages = msgList; + chat.sessionMsgs['s1'] = msgList; + const aMsg = msgList[1]; + chat.streamCtx['s1'] = { + sessionId: 's1', + msgList, + aMsg, + controller: null, + completedOnce: false, + events: [], + checkpointTimer: null, + lastCheckpointAt: 0, + reveal: { text: '全文内容', aMsg }, + abortAnn: null, + }; + return aMsg; +} + +async function mountPanel() { + const w = mount(ChatPanel, { global: { stubs: { teleport: true } } }); + await flushPromises(); + return w; +} + +describe('r3-07-01 跳过按钮事件链', () => { + beforeEach(() => { + setActivePinia(createPinia()); + const sessions = useSessionsStore(); + sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }]; + }); + + it('全链路:skip-btn 点击 → MessageList 转发 → ChatPanel → store.skipReveal 定格终态', async () => { + const chat = useChatStore(); + seedRevealing(chat); + + const w = await mountPanel(); + // 揭示中:skip-btn 存在 + const skipBtn = w.find('.skip-btn'); + expect(skipBtn.exists()).toBe(true); + + await skipBtn.trigger('click'); + await flushPromises(); + + // store 效果:stream 块移除、text 块定格、状态 done、reveal 清空 + const aMsg = chat.messages[1]; + expect(aMsg.status).toBe('done'); + expect(aMsg.blocks.some((b) => b.type === 'stream')).toBe(false); + expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true); + expect(chat.reveal).toBeNull(); + }); + + it('MessageList 层:AssistantMessage skip-reveal 事件被转发为同名 emit', async () => { + const chat = useChatStore(); + seedRevealing(chat); + const w = mount(MessageList, { + props: { messages: chat.messages, busy: false, reveal: chat.reveal, scrollSeq: 0 }, + global: { stubs: { transition: false } }, + }); + const am = w.findComponent(AssistantMessage); + expect(am.exists()).toBe(true); + await am.vm.$emit('skip-reveal'); + expect(w.emitted('skip-reveal')).toBeTruthy(); + expect(w.emitted('skip-reveal')).toHaveLength(1); + }); + + it('ChatPanel 层:MessageList skip-reveal 事件被监听(组件契约存在)', async () => { + const chat = useChatStore(); + seedRevealing(chat); + const w = await mountPanel(); + const ml = w.findComponent(MessageList); + expect(ml.exists()).toBe(true); + // 直接对 MessageList 发事件:ChatPanel 的 @skip-reveal 监听应落 store + await ml.vm.$emit('skip-reveal'); + await flushPromises(); + const aMsg = chat.messages[1]; + expect(aMsg.status).toBe('done'); + expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true); + }); +});