Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94fc1ef2d3 |
139
package-lock.json
generated
139
package-lock.json
generated
@ -11,15 +11,12 @@
|
|||||||
"@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",
|
||||||
@ -961,31 +958,6 @@
|
|||||||
"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",
|
||||||
@ -996,13 +968,6 @@
|
|||||||
"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",
|
||||||
@ -1704,22 +1669,6 @@
|
|||||||
"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",
|
||||||
@ -2070,15 +2019,6 @@
|
|||||||
"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",
|
||||||
@ -3318,25 +3258,6 @@
|
|||||||
"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",
|
||||||
@ -3404,45 +3325,6 @@
|
|||||||
"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",
|
||||||
@ -3459,12 +3341,6 @@
|
|||||||
"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",
|
||||||
@ -3927,15 +3803,6 @@
|
|||||||
"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",
|
||||||
@ -4371,12 +4238,6 @@
|
|||||||
"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,18 +11,15 @@
|
|||||||
"format": "prettier --write \"src/**/*.{ts,vue,css}\""
|
"format": "prettier --write \"src/**/*.{ts,vue,css}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/vue": "^1.34.0",
|
|
||||||
"axios": "^1.19.0",
|
|
||||||
"dingtalk-jsapi": "^3.2.9",
|
"dingtalk-jsapi": "^3.2.9",
|
||||||
"dompurify": "^3.4.17",
|
"axios": "^1.19.0",
|
||||||
"markdown-it": "^15.0.3",
|
"@lucide/vue": "^1.34.0",
|
||||||
"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",
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
|
||||||
FileQuestion,
|
|
||||||
PenLine,
|
PenLine,
|
||||||
Search,
|
Search,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
@ -141,18 +139,6 @@ function searchConversations() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<a
|
|
||||||
class="help-document"
|
|
||||||
href="https://alidocs.dingtalk.com/i/nodes/dpYLaezmVNZ9EBwXuKGavoNvVrMqPxX6?utm_scene=person_space"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="打开使用说明文档"
|
|
||||||
>
|
|
||||||
<FileQuestion :size="15" />
|
|
||||||
<span>使用说明文档</span>
|
|
||||||
<ExternalLink :size="12" class="help-document__external" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="knowledge-progress"><i></i></div>
|
<div class="knowledge-progress"><i></i></div>
|
||||||
<p>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p>
|
<p>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p>
|
||||||
@ -346,35 +332,8 @@ section h2 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-document {
|
|
||||||
display: flex;
|
|
||||||
min-height: 38px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
border-top: 1px solid var(--color-border-light);
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 0 16px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-document:hover {
|
|
||||||
background: var(--color-bg-warm);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-document span {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-document__external {
|
|
||||||
color: var(--color-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
margin-top: 0;
|
margin-top: auto;
|
||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-border-light);
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
@ -421,8 +380,6 @@ footer p {
|
|||||||
.conversation-search,
|
.conversation-search,
|
||||||
.sidebar-item__label,
|
.sidebar-item__label,
|
||||||
.new-label,
|
.new-label,
|
||||||
.help-document span,
|
|
||||||
.help-document__external,
|
|
||||||
footer {
|
footer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@ -432,13 +389,6 @@ footer p {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-document {
|
|
||||||
min-height: 42px;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-dot {
|
.risk-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
|
|||||||
@ -13,7 +13,6 @@ 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 {
|
||||||
@ -29,7 +28,6 @@ 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);
|
||||||
@ -232,10 +230,7 @@ function closeTool() {
|
|||||||
directResult.value = null;
|
directResult.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitDirectTool(
|
async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solution", query: string) {
|
||||||
kind: "supplier" | "material" | "cases" | "solution",
|
|
||||||
query: string,
|
|
||||||
) {
|
|
||||||
directLoading.value = true;
|
directLoading.value = true;
|
||||||
toolError.value = "";
|
toolError.value = "";
|
||||||
try {
|
try {
|
||||||
@ -243,8 +238,7 @@ async function submitDirectTool(
|
|||||||
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
|
directResponseCard.value = response.data.data as AssistantResponseCardData;
|
||||||
.data as AssistantResponseCardData;
|
|
||||||
closeTool();
|
closeTool();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
await scrollToBottom();
|
await scrollToBottom();
|
||||||
@ -252,9 +246,7 @@ async function submitDirectTool(
|
|||||||
} 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 instanceof Error ? reason.message : "查询失败";
|
||||||
? reason.message
|
|
||||||
: "查询失败";
|
|
||||||
} finally {
|
} finally {
|
||||||
directLoading.value = false;
|
directLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -306,19 +298,12 @@ async function submitTool() {
|
|||||||
toolError.value = "请先填写必填内容";
|
toolError.value = "请先填写必填内容";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")) {
|
||||||
["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")
|
await submitDirectTool(selectedTool as "supplier" | "material" | "cases" | "solution", finalValue);
|
||||||
) {
|
|
||||||
await submitDirectTool(
|
|
||||||
selectedTool as "supplier" | "material" | "cases" | "solution",
|
|
||||||
finalValue,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeTool();
|
closeTool();
|
||||||
void usePrompt(
|
void usePrompt(finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`);
|
||||||
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(event: Event) {
|
function selectFile(event: Event) {
|
||||||
@ -355,6 +340,10 @@ 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();
|
||||||
}
|
}
|
||||||
@ -365,8 +354,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -424,10 +411,10 @@ function isPrimaryRiskReport(
|
|||||||
): report is RiskAssessmentResult {
|
): report is RiskAssessmentResult {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
report &&
|
report &&
|
||||||
(report.dimensionScores?.length ||
|
(report.dimensionScores?.length ||
|
||||||
report.factors?.length ||
|
report.factors?.length ||
|
||||||
report.categoryWarning ||
|
report.categoryWarning ||
|
||||||
report.supplierFreq),
|
report.supplierFreq),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -435,7 +422,9 @@ function refreshPrimaryReportState() {
|
|||||||
const conversationId = conversation.value?.id;
|
const conversationId = conversation.value?.id;
|
||||||
primaryReportReady.value = Boolean(
|
primaryReportReady.value = Boolean(
|
||||||
conversationId &&
|
conversationId &&
|
||||||
isPrimaryRiskReport(readConversationReport(conversationId, auth.user?.id)),
|
isPrimaryRiskReport(
|
||||||
|
readConversationReport(conversationId, auth.user?.id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -452,12 +441,10 @@ function openReport(result: RiskAssessmentResult) {
|
|||||||
stored?.subjectId === result.subjectId
|
stored?.subjectId === result.subjectId
|
||||||
? {
|
? {
|
||||||
...cloneReport(result),
|
...cloneReport(result),
|
||||||
reportCards: storedCards.length
|
reportCards: storedCards.length ? storedCards : result.reportCards ?? [],
|
||||||
? 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);
|
||||||
@ -498,7 +485,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 || withdrawing.value) return;
|
if (!value || loading.value) return;
|
||||||
prompt.value = "";
|
prompt.value = "";
|
||||||
const sentConversation = await conversationStore.streamSend(
|
const sentConversation = await conversationStore.streamSend(
|
||||||
value,
|
value,
|
||||||
@ -564,14 +551,18 @@ 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="message.id > 0 && !generating"
|
v-if="
|
||||||
type="button"
|
message.id > 0 &&
|
||||||
:disabled="
|
!generating &&
|
||||||
withdrawing || conversationStore.loadingOlder || loading
|
hasAssistantPair(
|
||||||
|
conversation?.messages.indexOf(message) ?? -1,
|
||||||
|
)
|
||||||
"
|
"
|
||||||
|
type="button"
|
||||||
|
:disabled="withdrawingId !== null || loading"
|
||||||
@click="withdrawMessage(message.id)"
|
@click="withdrawMessage(message.id)"
|
||||||
>
|
>
|
||||||
{{ withdrawingId === message.id ? "撤回中…" : "撤回" }}
|
撤回
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -593,19 +584,12 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="message.content || isMessageThinking(message.id, messageIndex)"
|
||||||
message.content || isMessageThinking(message.id, messageIndex)
|
|
||||||
"
|
|
||||||
class="assistant-thinking"
|
class="assistant-thinking"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="assistant-thinking__header"
|
class="assistant-thinking__header"
|
||||||
:disabled="!message.riskResult && !message.responseCard"
|
|
||||||
:aria-expanded="
|
|
||||||
(!message.riskResult && !message.responseCard) ||
|
|
||||||
expandedReplies.has(message.id)
|
|
||||||
"
|
|
||||||
@click="toggleReply(message.id)"
|
@click="toggleReply(message.id)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -614,15 +598,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
>思考中</span
|
>思考中</span
|
||||||
>
|
>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
已完成<span
|
已完成<span v-if="thinkingDuration(messageIndex)" class="assistant-thinking__duration">
|
||||||
v-if="thinkingDuration(messageIndex)"
|
|
||||||
class="assistant-thinking__duration"
|
|
||||||
>
|
|
||||||
{{ thinkingDuration(messageIndex) }}s</span
|
{{ thinkingDuration(messageIndex) }}s</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
v-if="message.riskResult || message.responseCard"
|
|
||||||
:size="13"
|
:size="13"
|
||||||
class="assistant-thinking__arrow"
|
class="assistant-thinking__arrow"
|
||||||
:class="{ expanded: expandedReplies.has(message.id) }"
|
:class="{ expanded: expandedReplies.has(message.id) }"
|
||||||
@ -633,16 +613,10 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
class="assistant-bubble"
|
class="assistant-bubble"
|
||||||
:class="{
|
:class="{
|
||||||
'assistant-bubble--collapsed':
|
'assistant-bubble--collapsed':
|
||||||
(message.riskResult || message.responseCard) &&
|
|
||||||
!expandedReplies.has(message.id),
|
!expandedReplies.has(message.id),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
{{ message.content }}
|
||||||
<div
|
|
||||||
class="assistant-markdown"
|
|
||||||
v-html="renderSafeMarkdown(message.content)"
|
|
||||||
/>
|
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<RiskAnalysisCard
|
<RiskAnalysisCard
|
||||||
@ -667,9 +641,7 @@ 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">
|
<div class="message-avatar">{{ auth.user?.name?.slice(0, 1) ?? "我" }}</div>
|
||||||
{{ 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">
|
||||||
@ -701,21 +673,13 @@ 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 || withdrawing"
|
:loading="loading"
|
||||||
: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"
|
||||||
@ -786,114 +750,39 @@ 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">{{
|
<span class="direct-tier">{{ directResult.tier || "新供应商" }}</span>
|
||||||
directResult.tier || "新供应商"
|
|
||||||
}}</span>
|
|
||||||
<div class="direct-lines">
|
<div class="direct-lines">
|
||||||
<p>
|
<p>近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b>(代理合作频次口径)</p>
|
||||||
近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b
|
<p>基础分 <b>{{ directResult.supplierBaseScore ?? 70 }}</b> · α <b>×{{ Number(directResult.frequencyCoefficient ?? 1).toFixed(2) }}</b></p>
|
||||||
>(代理合作频次口径)
|
<p>供应商得分 S供 = <b>{{ directResult.supplierScore ?? 70 }}</b> 分</p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
基础分 <b>{{ directResult.supplierBaseScore ?? 70 }}</b> · α
|
|
||||||
<b
|
|
||||||
>×{{
|
|
||||||
Number(directResult.frequencyCoefficient ?? 1).toFixed(2)
|
|
||||||
}}</b
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
供应商得分 S供 =
|
|
||||||
<b>{{ directResult.supplierScore ?? 70 }}</b> 分
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p v-if="Array.isArray(directResult.topProblems) && directResult.topProblems.length">
|
||||||
v-if="
|
高频异常:{{ (directResult.topProblems as Array<{name: string; count: number}>).map((item) => `${item.name}(${item.count} 条)`).join("、") }}
|
||||||
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>
|
<p>物料:<b>{{ directResult.material || directResult.query }}</b> · 历史异常 {{ directResult.anomalyCount ?? 0 }} 条</p>
|
||||||
物料:<b>{{ directResult.material || directResult.query }}</b> ·
|
<p v-if="Array.isArray(directResult.frequentProblems) && directResult.frequentProblems.length">
|
||||||
历史异常 {{ directResult.anomalyCount ?? 0 }} 条
|
高频问题:{{ (directResult.frequentProblems as Array<{name: string; count: number}>).map((item) => `${item.name}(${item.count} 条)`).join("、") }}
|
||||||
</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>
|
<p>关键词:{{ directResult.query }} · 匹配 {{ directResult.matchedCount ?? 0 }} 条</p>
|
||||||
关键词:{{ directResult.query }} · 匹配
|
<ul v-if="Array.isArray(directResult.cases)" class="direct-case-list">
|
||||||
{{ directResult.matchedCount ?? 0 }} 条
|
<li v-for="(item, index) in (directResult.cases as Array<Record<string, unknown>>).slice(0, 5)" :key="index">
|
||||||
</p>
|
<b>{{ item.product || "未命名产品" }}</b>:{{ item.problem || item.description || "异常描述未填写" }}
|
||||||
<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>
|
<p>{{ directResult.analysisBase || "已结合历史案例与标准库生成分析方案。" }}</p>
|
||||||
{{
|
<ol v-if="Array.isArray(directResult.suggestions)" class="direct-suggestion-list">
|
||||||
directResult.analysisBase ||
|
<li v-for="item in directResult.suggestions as string[]" :key="item">{{ item }}</li>
|
||||||
"已结合历史案例与标准库生成分析方案。"
|
|
||||||
}}
|
|
||||||
</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>
|
||||||
@ -901,11 +790,7 @@ 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
|
<button type="submit" class="dialog-primary" :disabled="directLoading">
|
||||||
type="submit"
|
|
||||||
class="dialog-primary"
|
|
||||||
:disabled="directLoading"
|
|
||||||
>
|
|
||||||
{{ toolSubmitLabel }}
|
{{ toolSubmitLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1049,91 +934,6 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
white-space: pre-wrap;
|
|
||||||
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 {
|
||||||
@ -1144,13 +944,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -1169,16 +962,10 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition: background 0.15s, color 0.15s;
|
||||||
background 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-thinking__header:disabled {
|
.assistant-thinking__header:hover {
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-thinking__header:not(:disabled):hover {
|
|
||||||
background: var(--color-bg-warm);
|
background: var(--color-bg-warm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,177 +0,0 @@
|
|||||||
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,11 +29,8 @@ 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;
|
||||||
@ -68,12 +65,6 @@ 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 {
|
||||||
@ -81,11 +72,6 @@ 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
|
||||||
? {
|
? {
|
||||||
@ -108,7 +94,6 @@ 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
|
||||||
@ -165,7 +150,6 @@ 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 {
|
||||||
@ -200,7 +184,6 @@ 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(),
|
||||||
@ -404,55 +387,28 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
|
|
||||||
async function withdraw(messageId: number) {
|
async function withdraw(messageId: number) {
|
||||||
if (!current.value) return;
|
if (!current.value) return;
|
||||||
const original = current.value;
|
loading.value = true;
|
||||||
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(
|
||||||
conversationId,
|
current.value.id,
|
||||||
messageId,
|
messageId,
|
||||||
);
|
);
|
||||||
if (!response.data.data) throw new Error("撤回响应为空");
|
if (!response.data.data) throw new Error("撤回响应为空");
|
||||||
const result = response.data.data;
|
current.value = response.data.data;
|
||||||
// Keep already loaded older pages; the API returns only the newest page.
|
conversationCache.set(current.value.id, current.value);
|
||||||
const messages = new Map(remaining.map((message) => [message.id, message]));
|
const index = items.value.findIndex(
|
||||||
for (const message of result.messages) messages.set(message.id, message);
|
(item) => item.id === current.value?.id,
|
||||||
const mergedMessages = [...messages.values()].sort((left, right) =>
|
|
||||||
left.createdAt.localeCompare(right.createdAt) || left.id - right.id,
|
|
||||||
);
|
);
|
||||||
const merged = {
|
const summary = toSummary(current.value);
|
||||||
...result,
|
if (index >= 0) items.value.splice(index, 1, summary);
|
||||||
messages: mergedMessages,
|
else items.value.unshift(summary);
|
||||||
hasMoreMessages: mergedMessages.length < result.messagesTotal,
|
return current.value;
|
||||||
};
|
|
||||||
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.
|
error.value = reason instanceof Error ? reason.message : "撤回失败";
|
||||||
if (conversationCache.has(conversationId)) setConversation(original);
|
|
||||||
if (current.value?.id === conversationId)
|
|
||||||
error.value = reason instanceof Error ? reason.message : "撤回失败";
|
|
||||||
throw reason;
|
throw reason;
|
||||||
} finally {
|
} finally {
|
||||||
withdrawingConversations.delete(conversationId);
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -477,7 +433,6 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
loading,
|
loading,
|
||||||
sending,
|
sending,
|
||||||
generating,
|
generating,
|
||||||
withdrawing,
|
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
error,
|
error,
|
||||||
loadList,
|
loadList,
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
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=");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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