diff --git a/package-lock.json b/package-lock.json index 0031be2..30c59c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,15 @@ "@lucide/vue": "^1.34.0", "axios": "^1.19.0", "dingtalk-jsapi": "^3.2.9", + "dompurify": "^3.4.17", + "markdown-it": "^15.0.3", "pinia": "^4.0.3", "vue": "^3.5.41", "vue-router": "^5.2.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/markdown-it": "^14.2.0", "@types/node": "^26.2.0", "@vitejs/plugin-vue": "^6.0.8", "@vue/test-utils": "^2.4.11", @@ -958,6 +961,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", @@ -968,6 +996,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -1669,6 +1704,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz", + "integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2019,6 +2070,15 @@ "promise-polyfill": "^7.1.0" } }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3258,6 +3318,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, "node_modules/local-pkg": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", @@ -3325,6 +3404,45 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/markdown-it": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz", + "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.3", + "entities": "^8.1.0", + "linkify-it": "^6.1.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3341,6 +3459,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3803,6 +3927,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -4238,6 +4371,12 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "license": "MIT" + }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", diff --git a/package.json b/package.json index 1a64c03..bc9d5a8 100644 --- a/package.json +++ b/package.json @@ -11,15 +11,18 @@ "format": "prettier --write \"src/**/*.{ts,vue,css}\"" }, "dependencies": { - "dingtalk-jsapi": "^3.2.9", - "axios": "^1.19.0", "@lucide/vue": "^1.34.0", + "axios": "^1.19.0", + "dingtalk-jsapi": "^3.2.9", + "dompurify": "^3.4.17", + "markdown-it": "^15.0.3", "pinia": "^4.0.3", "vue": "^3.5.41", "vue-router": "^5.2.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/markdown-it": "^14.2.0", "@types/node": "^26.2.0", "@vitejs/plugin-vue": "^6.0.8", "@vue/test-utils": "^2.4.11", diff --git a/src/pages/RiskAssistantPage.vue b/src/pages/RiskAssistantPage.vue index 6eb0be7..f4b0b7e 100644 --- a/src/pages/RiskAssistantPage.vue +++ b/src/pages/RiskAssistantPage.vue @@ -13,6 +13,7 @@ import { directConversationQuery } from "@/api/conversations"; import { useConversationStore } from "@/stores/conversation.store"; import { useAuthStore } from "@/stores/auth.store"; import { storeToRefs } from "pinia"; +import { renderSafeMarkdown } from "@/utils/markdown"; import type { ReportCard, RiskAssessmentResult } from "@/types/risk"; import type { AssistantResponseCard as AssistantResponseCardData } from "@/types/conversation"; import { @@ -28,6 +29,7 @@ const { current: conversation, sending: loading, generating, + withdrawing, error, } = storeToRefs(conversationStore); const withdrawingId = ref(null); @@ -230,7 +232,10 @@ function closeTool() { directResult.value = null; } -async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solution", query: string) { +async function submitDirectTool( + kind: "supplier" | "material" | "cases" | "solution", + query: string, +) { directLoading.value = true; toolError.value = ""; try { @@ -238,7 +243,8 @@ async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solut directResult.value = response.data.data ?? null; if (response.data.data) { directUserPrompt.value = query; - directResponseCard.value = response.data.data as AssistantResponseCardData; + directResponseCard.value = response.data + .data as AssistantResponseCardData; closeTool(); await nextTick(); await scrollToBottom(); @@ -246,7 +252,9 @@ async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solut } catch (reason) { toolError.value = axios.isAxiosError(reason) ? String(reason.response?.data?.message || "查询失败") - : reason instanceof Error ? reason.message : "查询失败"; + : reason instanceof Error + ? reason.message + : "查询失败"; } finally { directLoading.value = false; } @@ -298,12 +306,19 @@ async function submitTool() { toolError.value = "请先填写必填内容"; return; } - if (["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")) { - await submitDirectTool(selectedTool as "supplier" | "material" | "cases" | "solution", finalValue); + if ( + ["supplier", "material", "cases", "solution"].includes(selectedTool ?? "") + ) { + await submitDirectTool( + selectedTool as "supplier" | "material" | "cases" | "solution", + finalValue, + ); return; } closeTool(); - void usePrompt(finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`); + void usePrompt( + finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`, + ); } function selectFile(event: Event) { @@ -340,10 +355,6 @@ function handleScroll() { if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder(); } -function hasAssistantPair(index: number) { - return conversation.value?.messages[index + 1]?.role === "assistant"; -} - function stopGeneration() { conversationStore.stopGeneration(); } @@ -354,6 +365,8 @@ async function withdrawMessage(messageId: number) { withdrawingId.value = messageId; try { await conversationStore.withdraw(messageId); + } catch { + // The store restores the message and exposes the failure in the page. } finally { withdrawingId.value = null; } @@ -411,10 +424,10 @@ function isPrimaryRiskReport( ): report is RiskAssessmentResult { return Boolean( report && - (report.dimensionScores?.length || - report.factors?.length || - report.categoryWarning || - report.supplierFreq), + (report.dimensionScores?.length || + report.factors?.length || + report.categoryWarning || + report.supplierFreq), ); } @@ -422,9 +435,7 @@ function refreshPrimaryReportState() { const conversationId = conversation.value?.id; primaryReportReady.value = Boolean( conversationId && - isPrimaryRiskReport( - readConversationReport(conversationId, auth.user?.id), - ), + isPrimaryRiskReport(readConversationReport(conversationId, auth.user?.id)), ); } @@ -441,10 +452,12 @@ function openReport(result: RiskAssessmentResult) { stored?.subjectId === result.subjectId ? { ...cloneReport(result), - reportCards: storedCards.length ? storedCards : result.reportCards ?? [], + reportCards: storedCards.length + ? storedCards + : (result.reportCards ?? []), aiSolutionCards: stored.aiSolutionCards?.length ? stored.aiSolutionCards - : result.aiSolutionCards ?? [], + : (result.aiSolutionCards ?? []), } : cloneReport(result); writeConversationReport(conversationId, merged, auth.user?.id); @@ -485,7 +498,7 @@ async function submit( messageType: "text" | "image" | "file" | "audio" = "text", ) { const value = prompt.value.trim(); - if (!value || loading.value) return; + if (!value || loading.value || withdrawing.value) return; prompt.value = ""; const sentConversation = await conversationStore.streamSend( value, @@ -551,18 +564,14 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
@@ -584,7 +593,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") { >
-
{{ auth.user?.name?.slice(0, 1) ?? "我" }}
+
+ {{ auth.user?.name?.slice(0, 1) ?? "我" }} +
{{ directUserPrompt }}
@@ -680,13 +701,21 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {

{{ error }}

+

+ 正在撤回消息… +

{{ toolError }}

@@ -797,7 +901,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") { - @@ -945,6 +1053,89 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") { overflow-wrap: anywhere; } +.assistant-markdown { + overflow-wrap: anywhere; + white-space: normal; +} + +.assistant-markdown :deep(p) { + margin: 0 0 0.75em; +} + +.assistant-markdown :deep(p:last-child), +.assistant-markdown :deep(ul:last-child), +.assistant-markdown :deep(ol:last-child), +.assistant-markdown :deep(table:last-child), +.assistant-markdown :deep(blockquote:last-child) { + margin-bottom: 0; +} + +.assistant-markdown :deep(ul), +.assistant-markdown :deep(ol) { + margin: 0 0 0.75em; + padding-left: 1.5em; +} + +.assistant-markdown :deep(h1), +.assistant-markdown :deep(h2), +.assistant-markdown :deep(h3), +.assistant-markdown :deep(h4) { + margin: 0.9em 0 0.45em; + color: var(--color-text); + font-size: 1em; + line-height: 1.45; +} + +.assistant-markdown :deep(table) { + display: block; + width: max-content; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + margin: 0.75em 0; + font-size: 0.95em; +} + +.assistant-markdown :deep(th), +.assistant-markdown :deep(td) { + border: 1px solid var(--color-border); + padding: 6px 9px; + text-align: left; + vertical-align: top; +} + +.assistant-markdown :deep(th) { + background: var(--color-bg-warm); + color: var(--color-text); + font-weight: 650; +} + +.assistant-markdown :deep(blockquote) { + border-left: 3px solid var(--color-border-strong); + margin: 0.75em 0; + padding-left: 0.9em; + color: var(--color-text-muted); +} + +.assistant-markdown :deep(pre) { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 10px; + background: var(--color-bg-warm); +} + +.assistant-markdown :deep(code) { + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 0.92em; +} + +.assistant-markdown :deep(a) { + color: var(--color-brand); +} + .assistant-bubble--collapsed { display: -webkit-box; max-height: calc(2 * 1.65em + 22px); @@ -953,6 +1144,13 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") { -webkit-line-clamp: 2; } +.assistant-bubble--collapsed .assistant-markdown { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .assistant-thinking { margin-bottom: 8px; } @@ -971,7 +1169,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") { font-size: 11px; font-family: inherit; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .assistant-thinking__header:disabled { diff --git a/src/stores/conversation.store.test.ts b/src/stores/conversation.store.test.ts new file mode 100644 index 0000000..c55636a --- /dev/null +++ b/src/stores/conversation.store.test.ts @@ -0,0 +1,177 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AxiosResponse } from "axios"; + +import { fetchConversation, withdrawConversationMessage } from "@/api/conversations"; +import { useConversationStore } from "@/stores/conversation.store"; +import type { ApiResponse } from "@/types/api"; +import type { Conversation, ConversationMessage } from "@/types/conversation"; + +vi.mock("@/api/conversations", () => ({ + createConversation: vi.fn(), + deleteConversation: vi.fn(), + fetchConversation: vi.fn(), + fetchConversations: vi.fn(), + withdrawConversationMessage: vi.fn(), + streamConversationMessage: vi.fn(), + sendConversationMessage: vi.fn(), +})); + +function conversation(id = 1, count = 26): Conversation { + return { + id, + title: `会话${id}`, + messageCount: count, + messagesTotal: count, + messagesOffset: 0, + hasMoreMessages: false, + lastRiskLevel: null, + updatedAt: "2026-10-27T10:00:00Z", + messages: Array.from({ length: count }, (_, index): ConversationMessage => ({ + id: index + 1, + role: index % 2 === 0 ? "user" : "assistant", + messageType: "text", + content: `消息${index + 1}`, + createdAt: `2026-10-27T10:00:${String(index).padStart(2, "0")}Z`, + riskResult: null, + responseCard: null, + semantic: null, + })), + }; +} + +function response(data: Conversation) { + return { data: { code: 200, message: "success", data } } as AxiosResponse>; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function afterWithdrawal(original: Conversation, ids = [5, 6]) { + const messages = original.messages.filter((message) => !ids.includes(message.id)); + return { + ...original, + messageCount: messages.length, + messagesTotal: messages.length, + messages: messages.slice(-20), + hasMoreMessages: messages.length > 20, + }; +} + +beforeEach(() => { + setActivePinia(createPinia()); + vi.resetAllMocks(); +}); + +describe("conversation withdrawal", () => { + it("removes the turn immediately, blocks conflicting sends, and preserves older pages on success", async () => { + const store = useConversationStore(); + const original = conversation(); + store.current = original; + const pending = deferred>(); + vi.mocked(withdrawConversationMessage).mockReturnValue(pending.promise); + + const operation = store.withdraw(5); + + expect(store.current?.messages.some((message) => [5, 6].includes(message.id))).toBe(false); + expect(store.current?.messageCount).toBe(24); + expect(store.items[0]?.messageCount).toBe(24); + expect(store.withdrawing).toBe(true); + expect(store.loading).toBe(false); + expect(store.generating).toBe(false); + await expect(store.withdraw(7)).rejects.toThrow("正在撤回消息"); + await expect(store.send("不要并发发送")).rejects.toThrow("正在撤回消息"); + await expect(store.streamSend("不要并发生成", vi.fn())).rejects.toThrow("正在撤回消息"); + + pending.resolve(response(afterWithdrawal(original))); + await operation; + + expect(withdrawConversationMessage).toHaveBeenCalledTimes(1); + expect(withdrawConversationMessage).toHaveBeenCalledWith(1, 5); + expect(store.current?.messages.map((message) => message.id)).toEqual( + original.messages.filter((message) => ![5, 6].includes(message.id)).map((message) => message.id), + ); + expect(store.current?.hasMoreMessages).toBe(false); + expect(store.withdrawing).toBe(false); + }); + + it("restores the turn and counts when deletion fails", async () => { + const store = useConversationStore(); + const original = conversation(); + store.current = original; + const pending = deferred>(); + vi.mocked(withdrawConversationMessage).mockReturnValue(pending.promise); + const operation = store.withdraw(5); + pending.reject(new Error("网络请求失败")); + + await expect(operation).rejects.toThrow("网络请求失败"); + + expect(store.current).toEqual(original); + expect(store.items[0]?.messageCount).toBe(26); + expect(store.error).toBe("网络请求失败"); + expect(store.withdrawing).toBe(false); + }); + + it.each([true, false])("does not switch conversations when pending deletion completes (success=%s)", async (succeeds) => { + const store = useConversationStore(); + const original = conversation(); + store.current = original; + const pending = deferred>(); + vi.mocked(withdrawConversationMessage).mockReturnValue(pending.promise); + const operation = store.withdraw(5); + vi.mocked(fetchConversation).mockResolvedValue(response(conversation(2))); + await store.open(2); + if (succeeds) { + pending.resolve(response(afterWithdrawal(original))); + await operation; + } else { + pending.reject(new Error("撤回失败")); + await expect(operation).rejects.toThrow("撤回失败"); + } + + expect(store.current?.id).toBe(2); + expect(store.current?.messageCount).toBe(26); + expect(store.error).toBeNull(); + expect(store.items.find((item) => item.id === 1)?.messageCount).toBe(succeeds ? 24 : 26); + }); + + it("does not restore stale messages from an earlier history request", async () => { + const store = useConversationStore(); + const original = conversation(); + store.current = original; + const history = deferred>(); + vi.mocked(fetchConversation).mockReturnValue(history.promise); + const opening = store.open(1); + const deletion = deferred>(); + vi.mocked(withdrawConversationMessage).mockReturnValue(deletion.promise); + const operation = store.withdraw(5); + // Reopening while withdrawal is pending uses the optimistic cache. + await store.open(1); + expect(fetchConversation).toHaveBeenCalledTimes(1); + deletion.resolve(response(afterWithdrawal(original))); + await operation; + history.resolve(response(original)); + await opening; + + expect(store.current?.messageCount).toBe(24); + expect(store.current?.messages.some((message) => message.id === 5)).toBe(false); + }); + + it("does not remove the following user message when there is no assistant reply", async () => { + const store = useConversationStore(); + const original = conversation(1, 3); + original.messages[1]!.role = "user"; + store.current = original; + const pending = deferred>(); + vi.mocked(withdrawConversationMessage).mockReturnValue(pending.promise); + const operation = store.withdraw(1); + expect(store.current?.messages.map((message) => message.id)).toEqual([2, 3]); + pending.resolve(response(afterWithdrawal(original, [1]))); + await operation; + expect(store.current?.messagesTotal).toBe(2); + }); +}); diff --git a/src/stores/conversation.store.ts b/src/stores/conversation.store.ts index 618937e..398e9da 100644 --- a/src/stores/conversation.store.ts +++ b/src/stores/conversation.store.ts @@ -29,8 +29,11 @@ export const useConversationStore = defineStore("conversation", () => { }; const streamStates = reactive(new Map()); const conversationCache = reactive(new Map()); + const withdrawingConversations = reactive(new Set()); + const conversationVersions = new Map(); const sending = computed(() => plainSending.value || Boolean(current.value && streamStates.has(current.value.id))); const generating = sending; + const withdrawing = computed(() => Boolean(current.value && withdrawingConversations.has(current.value.id))); let listRequest: Promise | null = null; let listRequestSearch: string | undefined; let listLoadedAt = 0; @@ -65,6 +68,12 @@ export const useConversationStore = defineStore("conversation", () => { } async function open(id: number) { + const version = conversationVersions.get(id) ?? 0; + const pending = conversationCache.get(id); + if (withdrawingConversations.has(id) && pending) { + current.value = pending; + return; + } loading.value = true; error.value = null; try { @@ -72,6 +81,11 @@ export const useConversationStore = defineStore("conversation", () => { if (!response.data.data) throw new Error("对话响应为空"); const remote = response.data.data; const cached = conversationCache.get(id); + // A GET started before withdrawal must not put the deleted turn back. + if ((conversationVersions.get(id) ?? 0) !== version && cached) { + current.value = cached; + return; + } const stream = streamStates.get(id); const merged = stream && cached ? { @@ -94,6 +108,7 @@ export const useConversationStore = defineStore("conversation", () => { async function loadOlder() { if ( loadingOlder.value || + withdrawing.value || !current.value || !current.value.messages.length || !current.value.messagesTotal @@ -150,6 +165,7 @@ export const useConversationStore = defineStore("conversation", () => { if (!current.value) await create(); if (!current.value) throw new Error("当前对话不存在"); const conversationId = current.value.id; + if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候"); plainSending.value = true; error.value = null; try { @@ -184,6 +200,7 @@ export const useConversationStore = defineStore("conversation", () => { if (!current.value) await create(); if (!current.value) throw new Error("当前对话不存在"); const conversationId = current.value.id; + if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候"); if (streamStates.has(conversationId)) throw new Error("当前会话正在生成,请稍候"); const state: StreamState = { controller: new AbortController(), @@ -387,28 +404,55 @@ export const useConversationStore = defineStore("conversation", () => { async function withdraw(messageId: number) { if (!current.value) return; - loading.value = true; + const original = current.value; + const conversationId = original.id; + if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候"); + if (sending.value || loadingOlder.value) throw new Error("当前会话正在处理,请稍候再撤回"); + const index = original.messages.findIndex((message) => message.id === messageId && message.role === "user"); + if (messageId <= 0 || index < 0) throw new Error("只能撤回已保存的用户消息"); + const removed = new Set([messageId]); + const next = original.messages[index + 1]; + if (next?.role === "assistant") removed.add(next.id); + const remaining = original.messages.filter((message) => !removed.has(message.id)); + withdrawingConversations.add(conversationId); + conversationVersions.set(conversationId, (conversationVersions.get(conversationId) ?? 0) + 1); + setConversation({ + ...original, + messages: remaining, + messageCount: Math.max(0, original.messageCount - removed.size), + messagesTotal: Math.max(0, original.messagesTotal - removed.size), + hasMoreMessages: original.messagesOffset + remaining.length < original.messagesTotal - removed.size, + lastRiskLevel: [...remaining].reverse().find((message) => message.riskResult)?.riskResult?.level ?? null, + }); error.value = null; try { const response = await withdrawConversationMessage( - current.value.id, + conversationId, messageId, ); if (!response.data.data) throw new Error("撤回响应为空"); - current.value = response.data.data; - conversationCache.set(current.value.id, current.value); - const index = items.value.findIndex( - (item) => item.id === current.value?.id, + const result = response.data.data; + // Keep already loaded older pages; the API returns only the newest page. + const messages = new Map(remaining.map((message) => [message.id, message])); + for (const message of result.messages) messages.set(message.id, message); + const mergedMessages = [...messages.values()].sort((left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id - right.id, ); - const summary = toSummary(current.value); - if (index >= 0) items.value.splice(index, 1, summary); - else items.value.unshift(summary); - return current.value; + const merged = { + ...result, + messages: mergedMessages, + hasMoreMessages: mergedMessages.length < result.messagesTotal, + }; + if (conversationCache.has(conversationId)) setConversation(merged); + return merged; } catch (reason) { - error.value = reason instanceof Error ? reason.message : "撤回失败"; + // Restore both the turn and the sidebar count if the server rejects deletion. + if (conversationCache.has(conversationId)) setConversation(original); + if (current.value?.id === conversationId) + error.value = reason instanceof Error ? reason.message : "撤回失败"; throw reason; } finally { - loading.value = false; + withdrawingConversations.delete(conversationId); } } @@ -433,6 +477,7 @@ export const useConversationStore = defineStore("conversation", () => { loading, sending, generating, + withdrawing, loadingOlder, error, loadList, diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts new file mode 100644 index 0000000..b61c51f --- /dev/null +++ b/src/utils/markdown.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { renderSafeMarkdown } from "@/utils/markdown"; + +describe("renderSafeMarkdown", () => { + it("renders a Markdown table as semantic table elements", () => { + const html = renderSafeMarkdown( + [ + "【风险清单】", + "", + "| 宣称/成分 | 风险等级 | 违规点 | 整改建议 |", + "| --- | --- | --- | --- |", + "| 淡化细纹 | 中 | 缺少功效检测报告 | 补充检测及备案支持 |", + ].join("\n"), + ); + + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).toContain(""); + }); + + it("preserves ordinary Markdown blocks and escaped literal HTML", () => { + const html = renderSafeMarkdown( + "## 结论\n\n这不是可信 HTML\n\n- 请补充报告", + ); + + expect(html).toContain("

结论

"); + expect(html).toContain("<strong>"); + expect(html).toContain("
  • 请补充报告
  • "); + }); + + it("sanitizes unsafe Markdown links and generated markup", () => { + const html = renderSafeMarkdown( + '[点击](javascript:alert(1)) ', + ); + + expect(html).not.toContain('
    宣称/成分淡化细纹补充检测及备案支持