渲染助手Markdown表格并优化撤回交互
This commit is contained in:
parent
591cce3a98
commit
28cf53dcf6
139
package-lock.json
generated
139
package-lock.json
generated
@ -11,12 +11,15 @@
|
|||||||
"@lucide/vue": "^1.34.0",
|
"@lucide/vue": "^1.34.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"dingtalk-jsapi": "^3.2.9",
|
"dingtalk-jsapi": "^3.2.9",
|
||||||
|
"dompurify": "^3.4.17",
|
||||||
|
"markdown-it": "^15.0.3",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^5.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/markdown-it": "^14.2.0",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.8",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
@ -958,6 +961,31 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.4.0",
|
"version": "26.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
|
||||||
@ -968,6 +996,13 @@
|
|||||||
"undici-types": "~8.3.0"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.68.0",
|
"version": "8.68.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
|
"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"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@ -2019,6 +2070,15 @@
|
|||||||
"promise-polyfill": "^7.1.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -3258,6 +3318,25 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/local-pkg": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
|
||||||
@ -3325,6 +3404,45 @@
|
|||||||
"url": "https://github.com/sponsors/sxzz"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@ -3341,6 +3459,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "CC0-1.0"
|
"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": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
@ -3803,6 +3927,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/quansync": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||||
@ -4238,6 +4371,12 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"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": {
|
"node_modules/ufo": {
|
||||||
"version": "1.6.4",
|
"version": "1.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
||||||
|
|||||||
@ -11,15 +11,18 @@
|
|||||||
"format": "prettier --write \"src/**/*.{ts,vue,css}\""
|
"format": "prettier --write \"src/**/*.{ts,vue,css}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dingtalk-jsapi": "^3.2.9",
|
|
||||||
"axios": "^1.19.0",
|
|
||||||
"@lucide/vue": "^1.34.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",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^5.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/markdown-it": "^14.2.0",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.8",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { directConversationQuery } from "@/api/conversations";
|
|||||||
import { useConversationStore } from "@/stores/conversation.store";
|
import { useConversationStore } from "@/stores/conversation.store";
|
||||||
import { useAuthStore } from "@/stores/auth.store";
|
import { useAuthStore } from "@/stores/auth.store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
import { renderSafeMarkdown } from "@/utils/markdown";
|
||||||
import type { ReportCard, RiskAssessmentResult } from "@/types/risk";
|
import type { ReportCard, RiskAssessmentResult } from "@/types/risk";
|
||||||
import type { AssistantResponseCard as AssistantResponseCardData } from "@/types/conversation";
|
import type { AssistantResponseCard as AssistantResponseCardData } from "@/types/conversation";
|
||||||
import {
|
import {
|
||||||
@ -28,6 +29,7 @@ const {
|
|||||||
current: conversation,
|
current: conversation,
|
||||||
sending: loading,
|
sending: loading,
|
||||||
generating,
|
generating,
|
||||||
|
withdrawing,
|
||||||
error,
|
error,
|
||||||
} = storeToRefs(conversationStore);
|
} = storeToRefs(conversationStore);
|
||||||
const withdrawingId = ref<number | null>(null);
|
const withdrawingId = ref<number | null>(null);
|
||||||
@ -230,7 +232,10 @@ function closeTool() {
|
|||||||
directResult.value = null;
|
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;
|
directLoading.value = true;
|
||||||
toolError.value = "";
|
toolError.value = "";
|
||||||
try {
|
try {
|
||||||
@ -238,7 +243,8 @@ async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solut
|
|||||||
directResult.value = response.data.data ?? null;
|
directResult.value = response.data.data ?? null;
|
||||||
if (response.data.data) {
|
if (response.data.data) {
|
||||||
directUserPrompt.value = query;
|
directUserPrompt.value = query;
|
||||||
directResponseCard.value = response.data.data as AssistantResponseCardData;
|
directResponseCard.value = response.data
|
||||||
|
.data as AssistantResponseCardData;
|
||||||
closeTool();
|
closeTool();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
await scrollToBottom();
|
await scrollToBottom();
|
||||||
@ -246,7 +252,9 @@ async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solut
|
|||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
toolError.value = axios.isAxiosError(reason)
|
toolError.value = axios.isAxiosError(reason)
|
||||||
? String(reason.response?.data?.message || "查询失败")
|
? String(reason.response?.data?.message || "查询失败")
|
||||||
: reason instanceof Error ? reason.message : "查询失败";
|
: reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "查询失败";
|
||||||
} finally {
|
} finally {
|
||||||
directLoading.value = false;
|
directLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -298,12 +306,19 @@ async function submitTool() {
|
|||||||
toolError.value = "请先填写必填内容";
|
toolError.value = "请先填写必填内容";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")) {
|
if (
|
||||||
await submitDirectTool(selectedTool as "supplier" | "material" | "cases" | "solution", finalValue);
|
["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")
|
||||||
|
) {
|
||||||
|
await submitDirectTool(
|
||||||
|
selectedTool as "supplier" | "material" | "cases" | "solution",
|
||||||
|
finalValue,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeTool();
|
closeTool();
|
||||||
void usePrompt(finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`);
|
void usePrompt(
|
||||||
|
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(event: Event) {
|
function selectFile(event: Event) {
|
||||||
@ -340,10 +355,6 @@ function handleScroll() {
|
|||||||
if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder();
|
if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAssistantPair(index: number) {
|
|
||||||
return conversation.value?.messages[index + 1]?.role === "assistant";
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopGeneration() {
|
function stopGeneration() {
|
||||||
conversationStore.stopGeneration();
|
conversationStore.stopGeneration();
|
||||||
}
|
}
|
||||||
@ -354,6 +365,8 @@ async function withdrawMessage(messageId: number) {
|
|||||||
withdrawingId.value = messageId;
|
withdrawingId.value = messageId;
|
||||||
try {
|
try {
|
||||||
await conversationStore.withdraw(messageId);
|
await conversationStore.withdraw(messageId);
|
||||||
|
} catch {
|
||||||
|
// The store restores the message and exposes the failure in the page.
|
||||||
} finally {
|
} finally {
|
||||||
withdrawingId.value = null;
|
withdrawingId.value = null;
|
||||||
}
|
}
|
||||||
@ -422,9 +435,7 @@ function refreshPrimaryReportState() {
|
|||||||
const conversationId = conversation.value?.id;
|
const conversationId = conversation.value?.id;
|
||||||
primaryReportReady.value = Boolean(
|
primaryReportReady.value = Boolean(
|
||||||
conversationId &&
|
conversationId &&
|
||||||
isPrimaryRiskReport(
|
isPrimaryRiskReport(readConversationReport(conversationId, auth.user?.id)),
|
||||||
readConversationReport(conversationId, auth.user?.id),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -441,10 +452,12 @@ function openReport(result: RiskAssessmentResult) {
|
|||||||
stored?.subjectId === result.subjectId
|
stored?.subjectId === result.subjectId
|
||||||
? {
|
? {
|
||||||
...cloneReport(result),
|
...cloneReport(result),
|
||||||
reportCards: storedCards.length ? storedCards : result.reportCards ?? [],
|
reportCards: storedCards.length
|
||||||
|
? storedCards
|
||||||
|
: (result.reportCards ?? []),
|
||||||
aiSolutionCards: stored.aiSolutionCards?.length
|
aiSolutionCards: stored.aiSolutionCards?.length
|
||||||
? stored.aiSolutionCards
|
? stored.aiSolutionCards
|
||||||
: result.aiSolutionCards ?? [],
|
: (result.aiSolutionCards ?? []),
|
||||||
}
|
}
|
||||||
: cloneReport(result);
|
: cloneReport(result);
|
||||||
writeConversationReport(conversationId, merged, auth.user?.id);
|
writeConversationReport(conversationId, merged, auth.user?.id);
|
||||||
@ -485,7 +498,7 @@ async function submit(
|
|||||||
messageType: "text" | "image" | "file" | "audio" = "text",
|
messageType: "text" | "image" | "file" | "audio" = "text",
|
||||||
) {
|
) {
|
||||||
const value = prompt.value.trim();
|
const value = prompt.value.trim();
|
||||||
if (!value || loading.value) return;
|
if (!value || loading.value || withdrawing.value) return;
|
||||||
prompt.value = "";
|
prompt.value = "";
|
||||||
const sentConversation = await conversationStore.streamSend(
|
const sentConversation = await conversationStore.streamSend(
|
||||||
value,
|
value,
|
||||||
@ -551,18 +564,14 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<time>{{ formatMessageTime(message.createdAt) }}</time>
|
<time>{{ formatMessageTime(message.createdAt) }}</time>
|
||||||
<div class="user-message-actions">
|
<div class="user-message-actions">
|
||||||
<button
|
<button
|
||||||
v-if="
|
v-if="message.id > 0 && !generating"
|
||||||
message.id > 0 &&
|
|
||||||
!generating &&
|
|
||||||
hasAssistantPair(
|
|
||||||
conversation?.messages.indexOf(message) ?? -1,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="withdrawingId !== null || loading"
|
:disabled="
|
||||||
|
withdrawing || conversationStore.loadingOlder || loading
|
||||||
|
"
|
||||||
@click="withdrawMessage(message.id)"
|
@click="withdrawMessage(message.id)"
|
||||||
>
|
>
|
||||||
撤回
|
{{ withdrawingId === message.id ? "撤回中…" : "撤回" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -584,7 +593,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="message.content || isMessageThinking(message.id, messageIndex)"
|
v-if="
|
||||||
|
message.content || isMessageThinking(message.id, messageIndex)
|
||||||
|
"
|
||||||
class="assistant-thinking"
|
class="assistant-thinking"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@ -603,7 +614,10 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
>思考中</span
|
>思考中</span
|
||||||
>
|
>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
已完成<span v-if="thinkingDuration(messageIndex)" class="assistant-thinking__duration">
|
已完成<span
|
||||||
|
v-if="thinkingDuration(messageIndex)"
|
||||||
|
class="assistant-thinking__duration"
|
||||||
|
>
|
||||||
{{ thinkingDuration(messageIndex) }}s</span
|
{{ thinkingDuration(messageIndex) }}s</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
@ -623,7 +637,12 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
!expandedReplies.has(message.id),
|
!expandedReplies.has(message.id),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ message.content }}
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
class="assistant-markdown"
|
||||||
|
v-html="renderSafeMarkdown(message.content)"
|
||||||
|
/>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<RiskAnalysisCard
|
<RiskAnalysisCard
|
||||||
@ -648,7 +667,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
|
|
||||||
<template v-if="directResponseCard">
|
<template v-if="directResponseCard">
|
||||||
<div class="message message--user">
|
<div class="message message--user">
|
||||||
<div class="message-avatar">{{ auth.user?.name?.slice(0, 1) ?? "我" }}</div>
|
<div class="message-avatar">
|
||||||
|
{{ auth.user?.name?.slice(0, 1) ?? "我" }}
|
||||||
|
</div>
|
||||||
<div class="user-bubble">{{ directUserPrompt }}</div>
|
<div class="user-bubble">{{ directUserPrompt }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message message--assistant">
|
<div class="message message--assistant">
|
||||||
@ -680,13 +701,21 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<p v-if="error" class="state-message state-message--error">
|
<p v-if="error" class="state-message state-message--error">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="withdrawing"
|
||||||
|
class="state-message"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
正在撤回消息…
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssistantComposer
|
<AssistantComposer
|
||||||
ref="composer"
|
ref="composer"
|
||||||
v-model="prompt"
|
v-model="prompt"
|
||||||
:loading="loading"
|
:loading="loading || withdrawing"
|
||||||
:can-upload="auth.hasPermission('risk_item.submit')"
|
:can-upload="auth.hasPermission('risk_item.submit')"
|
||||||
:can-review="auth.hasPermission('risk_item.review')"
|
:can-review="auth.hasPermission('risk_item.review')"
|
||||||
@submit="submit"
|
@submit="submit"
|
||||||
@ -757,39 +786,114 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<p v-if="toolError" class="dialog-error">{{ toolError }}</p>
|
<p v-if="toolError" class="dialog-error">{{ toolError }}</p>
|
||||||
<section v-if="directResult" class="direct-result-card">
|
<section v-if="directResult" class="direct-result-card">
|
||||||
<template v-if="tool === 'supplier'">
|
<template v-if="tool === 'supplier'">
|
||||||
<span class="direct-tier">{{ directResult.tier || "新供应商" }}</span>
|
<span class="direct-tier">{{
|
||||||
|
directResult.tier || "新供应商"
|
||||||
|
}}</span>
|
||||||
<div class="direct-lines">
|
<div class="direct-lines">
|
||||||
<p>近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b>(代理合作频次口径)</p>
|
<p>
|
||||||
<p>基础分 <b>{{ directResult.supplierBaseScore ?? 70 }}</b> · α <b>×{{ Number(directResult.frequencyCoefficient ?? 1).toFixed(2) }}</b></p>
|
近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b
|
||||||
<p>供应商得分 S供 = <b>{{ directResult.supplierScore ?? 70 }}</b> 分</p>
|
>(代理合作频次口径)
|
||||||
</div>
|
</p>
|
||||||
<p v-if="Array.isArray(directResult.topProblems) && directResult.topProblems.length">
|
<p>
|
||||||
高频异常:{{ (directResult.topProblems as Array<{name: string; count: number}>).map((item) => `${item.name}(${item.count} 条)`).join("、") }}
|
基础分 <b>{{ directResult.supplierBaseScore ?? 70 }}</b> · α
|
||||||
|
<b
|
||||||
|
>×{{
|
||||||
|
Number(directResult.frequencyCoefficient ?? 1).toFixed(2)
|
||||||
|
}}</b
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
供应商得分 S供 =
|
||||||
|
<b>{{ directResult.supplierScore ?? 70 }}</b> 分
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="
|
||||||
|
Array.isArray(directResult.topProblems) &&
|
||||||
|
directResult.topProblems.length
|
||||||
|
"
|
||||||
|
>
|
||||||
|
高频异常:{{
|
||||||
|
(
|
||||||
|
directResult.topProblems as Array<{
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
.map((item) => `${item.name}(${item.count} 条)`)
|
||||||
|
.join("、")
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="Array.isArray(directResult.suggestions)">
|
||||||
|
建议:{{ (directResult.suggestions as string[]).join(" + ") }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="Array.isArray(directResult.suggestions)">建议:{{ (directResult.suggestions as string[]).join(" + ") }}</p>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="tool === 'material'">
|
<template v-else-if="tool === 'material'">
|
||||||
<strong>{{ directResult.title || "物料风险分析" }}</strong>
|
<strong>{{ directResult.title || "物料风险分析" }}</strong>
|
||||||
<p>物料:<b>{{ directResult.material || directResult.query }}</b> · 历史异常 {{ directResult.anomalyCount ?? 0 }} 条</p>
|
<p>
|
||||||
<p v-if="Array.isArray(directResult.frequentProblems) && directResult.frequentProblems.length">
|
物料:<b>{{ directResult.material || directResult.query }}</b> ·
|
||||||
高频问题:{{ (directResult.frequentProblems as Array<{name: string; count: number}>).map((item) => `${item.name}(${item.count} 条)`).join("、") }}
|
历史异常 {{ directResult.anomalyCount ?? 0 }} 条
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="
|
||||||
|
Array.isArray(directResult.frequentProblems) &&
|
||||||
|
directResult.frequentProblems.length
|
||||||
|
"
|
||||||
|
>
|
||||||
|
高频问题:{{
|
||||||
|
(
|
||||||
|
directResult.frequentProblems as Array<{
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
.map((item) => `${item.name}(${item.count} 条)`)
|
||||||
|
.join("、")
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="Array.isArray(directResult.suggestions)">
|
||||||
|
建议:{{ (directResult.suggestions as string[]).join(";") }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="Array.isArray(directResult.suggestions)">建议:{{ (directResult.suggestions as string[]).join(";") }}</p>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="tool === 'cases'">
|
<template v-else-if="tool === 'cases'">
|
||||||
<strong>相似案例检索</strong>
|
<strong>相似案例检索</strong>
|
||||||
<p>关键词:{{ directResult.query }} · 匹配 {{ directResult.matchedCount ?? 0 }} 条</p>
|
<p>
|
||||||
<ul v-if="Array.isArray(directResult.cases)" class="direct-case-list">
|
关键词:{{ directResult.query }} · 匹配
|
||||||
<li v-for="(item, index) in (directResult.cases as Array<Record<string, unknown>>).slice(0, 5)" :key="index">
|
{{ directResult.matchedCount ?? 0 }} 条
|
||||||
<b>{{ item.product || "未命名产品" }}</b>:{{ item.problem || item.description || "异常描述未填写" }}
|
</p>
|
||||||
|
<ul
|
||||||
|
v-if="Array.isArray(directResult.cases)"
|
||||||
|
class="direct-case-list"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in (
|
||||||
|
directResult.cases as Array<Record<string, unknown>>
|
||||||
|
).slice(0, 5)"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<b>{{ item.product || "未命名产品" }}</b
|
||||||
|
>:{{ item.problem || item.description || "异常描述未填写" }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<strong>{{ directResult.title || "AI 方案生成" }}</strong>
|
<strong>{{ directResult.title || "AI 方案生成" }}</strong>
|
||||||
<p>{{ directResult.analysisBase || "已结合历史案例与标准库生成分析方案。" }}</p>
|
<p>
|
||||||
<ol v-if="Array.isArray(directResult.suggestions)" class="direct-suggestion-list">
|
{{
|
||||||
<li v-for="item in directResult.suggestions as string[]" :key="item">{{ item }}</li>
|
directResult.analysisBase ||
|
||||||
|
"已结合历史案例与标准库生成分析方案。"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<ol
|
||||||
|
v-if="Array.isArray(directResult.suggestions)"
|
||||||
|
class="direct-suggestion-list"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="item in directResult.suggestions as string[]"
|
||||||
|
:key="item"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@ -797,7 +901,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<button type="button" class="dialog-secondary" @click="closeTool">
|
<button type="button" class="dialog-secondary" @click="closeTool">
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="dialog-primary" :disabled="directLoading">
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="dialog-primary"
|
||||||
|
:disabled="directLoading"
|
||||||
|
>
|
||||||
{{ toolSubmitLabel }}
|
{{ toolSubmitLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -945,6 +1053,89 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
overflow-wrap: anywhere;
|
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 {
|
.assistant-bubble--collapsed {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
max-height: calc(2 * 1.65em + 22px);
|
max-height: calc(2 * 1.65em + 22px);
|
||||||
@ -953,6 +1144,13 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-bubble--collapsed .assistant-markdown {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-thinking {
|
.assistant-thinking {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
@ -971,7 +1169,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-thinking__header:disabled {
|
.assistant-thinking__header:disabled {
|
||||||
|
|||||||
177
src/stores/conversation.store.test.ts
Normal file
177
src/stores/conversation.store.test.ts
Normal file
@ -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<ApiResponse<Conversation>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((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<ReturnType<typeof response>>();
|
||||||
|
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<ReturnType<typeof response>>();
|
||||||
|
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<ReturnType<typeof response>>();
|
||||||
|
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<ReturnType<typeof response>>();
|
||||||
|
vi.mocked(fetchConversation).mockReturnValue(history.promise);
|
||||||
|
const opening = store.open(1);
|
||||||
|
const deletion = deferred<ReturnType<typeof response>>();
|
||||||
|
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<ReturnType<typeof response>>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -29,8 +29,11 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
};
|
};
|
||||||
const streamStates = reactive(new Map<number, StreamState>());
|
const streamStates = reactive(new Map<number, StreamState>());
|
||||||
const conversationCache = reactive(new Map<number, Conversation>());
|
const conversationCache = reactive(new Map<number, Conversation>());
|
||||||
|
const withdrawingConversations = reactive(new Set<number>());
|
||||||
|
const conversationVersions = new Map<number, number>();
|
||||||
const sending = computed(() => plainSending.value || Boolean(current.value && streamStates.has(current.value.id)));
|
const sending = computed(() => plainSending.value || Boolean(current.value && streamStates.has(current.value.id)));
|
||||||
const generating = sending;
|
const generating = sending;
|
||||||
|
const withdrawing = computed(() => Boolean(current.value && withdrawingConversations.has(current.value.id)));
|
||||||
let listRequest: Promise<void> | null = null;
|
let listRequest: Promise<void> | null = null;
|
||||||
let listRequestSearch: string | undefined;
|
let listRequestSearch: string | undefined;
|
||||||
let listLoadedAt = 0;
|
let listLoadedAt = 0;
|
||||||
@ -65,6 +68,12 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function open(id: number) {
|
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;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
@ -72,6 +81,11 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
if (!response.data.data) throw new Error("对话响应为空");
|
if (!response.data.data) throw new Error("对话响应为空");
|
||||||
const remote = response.data.data;
|
const remote = response.data.data;
|
||||||
const cached = conversationCache.get(id);
|
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 stream = streamStates.get(id);
|
||||||
const merged = stream && cached
|
const merged = stream && cached
|
||||||
? {
|
? {
|
||||||
@ -94,6 +108,7 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
async function loadOlder() {
|
async function loadOlder() {
|
||||||
if (
|
if (
|
||||||
loadingOlder.value ||
|
loadingOlder.value ||
|
||||||
|
withdrawing.value ||
|
||||||
!current.value ||
|
!current.value ||
|
||||||
!current.value.messages.length ||
|
!current.value.messages.length ||
|
||||||
!current.value.messagesTotal
|
!current.value.messagesTotal
|
||||||
@ -150,6 +165,7 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
if (!current.value) await create();
|
if (!current.value) await create();
|
||||||
if (!current.value) throw new Error("当前对话不存在");
|
if (!current.value) throw new Error("当前对话不存在");
|
||||||
const conversationId = current.value.id;
|
const conversationId = current.value.id;
|
||||||
|
if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候");
|
||||||
plainSending.value = true;
|
plainSending.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
@ -184,6 +200,7 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
if (!current.value) await create();
|
if (!current.value) await create();
|
||||||
if (!current.value) throw new Error("当前对话不存在");
|
if (!current.value) throw new Error("当前对话不存在");
|
||||||
const conversationId = current.value.id;
|
const conversationId = current.value.id;
|
||||||
|
if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候");
|
||||||
if (streamStates.has(conversationId)) throw new Error("当前会话正在生成,请稍候");
|
if (streamStates.has(conversationId)) throw new Error("当前会话正在生成,请稍候");
|
||||||
const state: StreamState = {
|
const state: StreamState = {
|
||||||
controller: new AbortController(),
|
controller: new AbortController(),
|
||||||
@ -387,28 +404,55 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
|
|
||||||
async function withdraw(messageId: number) {
|
async function withdraw(messageId: number) {
|
||||||
if (!current.value) return;
|
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;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const response = await withdrawConversationMessage(
|
const response = await withdrawConversationMessage(
|
||||||
current.value.id,
|
conversationId,
|
||||||
messageId,
|
messageId,
|
||||||
);
|
);
|
||||||
if (!response.data.data) throw new Error("撤回响应为空");
|
if (!response.data.data) throw new Error("撤回响应为空");
|
||||||
current.value = response.data.data;
|
const result = response.data.data;
|
||||||
conversationCache.set(current.value.id, current.value);
|
// Keep already loaded older pages; the API returns only the newest page.
|
||||||
const index = items.value.findIndex(
|
const messages = new Map(remaining.map((message) => [message.id, message]));
|
||||||
(item) => item.id === current.value?.id,
|
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);
|
const merged = {
|
||||||
if (index >= 0) items.value.splice(index, 1, summary);
|
...result,
|
||||||
else items.value.unshift(summary);
|
messages: mergedMessages,
|
||||||
return current.value;
|
hasMoreMessages: mergedMessages.length < result.messagesTotal,
|
||||||
|
};
|
||||||
|
if (conversationCache.has(conversationId)) setConversation(merged);
|
||||||
|
return merged;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
// 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 : "撤回失败";
|
error.value = reason instanceof Error ? reason.message : "撤回失败";
|
||||||
throw reason;
|
throw reason;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
withdrawingConversations.delete(conversationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,6 +477,7 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
loading,
|
loading,
|
||||||
sending,
|
sending,
|
||||||
generating,
|
generating,
|
||||||
|
withdrawing,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
error,
|
error,
|
||||||
loadList,
|
loadList,
|
||||||
|
|||||||
43
src/utils/markdown.test.ts
Normal file
43
src/utils/markdown.test.ts
Normal file
@ -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("<table>");
|
||||||
|
expect(html).toContain("<th>宣称/成分</th>");
|
||||||
|
expect(html).toContain("<td>淡化细纹</td>");
|
||||||
|
expect(html).toContain("<td>补充检测及备案支持</td>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves ordinary Markdown blocks and escaped literal HTML", () => {
|
||||||
|
const html = renderSafeMarkdown(
|
||||||
|
"## 结论\n\n<strong>这不是可信 HTML</strong>\n\n- 请补充报告",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("<h2>结论</h2>");
|
||||||
|
expect(html).toContain("<strong>");
|
||||||
|
expect(html).toContain("<li>请补充报告</li>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes unsafe Markdown links and generated markup", () => {
|
||||||
|
const html = renderSafeMarkdown(
|
||||||
|
'[点击](javascript:alert(1)) <img src=x onerror="alert(1)">',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).not.toContain('<a href="javascript:');
|
||||||
|
expect(html).not.toContain("<img src=");
|
||||||
|
expect(html).toContain("<img");
|
||||||
|
expect(html).toContain("onerror=");
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/utils/markdown.ts
Normal file
41
src/utils/markdown.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
|
||||||
|
const parser = new MarkdownIt({
|
||||||
|
breaks: true,
|
||||||
|
html: false,
|
||||||
|
linkify: false,
|
||||||
|
typographer: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function renderSafeMarkdown(source: string): string {
|
||||||
|
return DOMPurify.sanitize(parser.render(source), {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
"a",
|
||||||
|
"blockquote",
|
||||||
|
"br",
|
||||||
|
"code",
|
||||||
|
"del",
|
||||||
|
"em",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"hr",
|
||||||
|
"li",
|
||||||
|
"ol",
|
||||||
|
"p",
|
||||||
|
"pre",
|
||||||
|
"strong",
|
||||||
|
"table",
|
||||||
|
"tbody",
|
||||||
|
"td",
|
||||||
|
"th",
|
||||||
|
"thead",
|
||||||
|
"tr",
|
||||||
|
"ul",
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ["href", "rel", "target"],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user