Compare commits

..

1 Commits
dev ... main

Author SHA1 Message Date
94fc1ef2d3 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-09-21 10:50:45 +08:00
8 changed files with 70 additions and 781 deletions

139
package-lock.json generated
View File

@ -11,15 +11,12 @@
"@lucide/vue": "^1.34.0",
"axios": "^1.19.0",
"dingtalk-jsapi": "^3.2.9",
"dompurify": "^3.4.17",
"markdown-it": "^15.0.3",
"pinia": "^4.0.3",
"vue": "^3.5.41",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/markdown-it": "^14.2.0",
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11",
@ -961,31 +958,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
@ -996,13 +968,6 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
@ -1704,22 +1669,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz",
"integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "PSF-2.0"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -2070,15 +2019,6 @@
"promise-polyfill": "^7.1.0"
}
},
"node_modules/dompurify": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -3318,25 +3258,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkify-it": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
"integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^3.0.0"
}
},
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
@ -3404,45 +3325,6 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/markdown-it": {
"version": "15.0.3",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz",
"integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^3.0.3",
"entities": "^8.1.0",
"linkify-it": "^6.1.0",
"mdurl": "^2.1.0",
"punycode.js": "^2.3.1",
"uc.micro": "^3.0.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3459,12 +3341,6 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/mdurl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -3927,15 +3803,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": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -4371,12 +4238,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uc.micro": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",

View File

@ -11,18 +11,15 @@
"format": "prettier --write \"src/**/*.{ts,vue,css}\""
},
"dependencies": {
"@lucide/vue": "^1.34.0",
"axios": "^1.19.0",
"dingtalk-jsapi": "^3.2.9",
"dompurify": "^3.4.17",
"markdown-it": "^15.0.3",
"axios": "^1.19.0",
"@lucide/vue": "^1.34.0",
"pinia": "^4.0.3",
"vue": "^3.5.41",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/markdown-it": "^14.2.0",
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11",

View File

@ -1,7 +1,5 @@
<script setup lang="ts">
import {
ExternalLink,
FileQuestion,
PenLine,
Search,
} from "@lucide/vue";
@ -141,18 +139,6 @@ function searchConversations() {
</div>
</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>
<div class="knowledge-progress"><i></i></div>
<p>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p>
@ -346,35 +332,8 @@ section h2 {
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 {
margin-top: 0;
margin-top: auto;
border-top: 1px solid var(--color-border-light);
padding: 12px 16px;
}
@ -421,8 +380,6 @@ footer p {
.conversation-search,
.sidebar-item__label,
.new-label,
.help-document span,
.help-document__external,
footer {
display: none;
}
@ -432,13 +389,6 @@ footer p {
padding: 0;
}
.help-document {
min-height: 42px;
justify-content: center;
margin-top: auto;
padding: 0;
}
.risk-dot {
width: 7px;
height: 7px;

View File

@ -13,7 +13,6 @@ import { directConversationQuery } from "@/api/conversations";
import { useConversationStore } from "@/stores/conversation.store";
import { useAuthStore } from "@/stores/auth.store";
import { storeToRefs } from "pinia";
import { renderSafeMarkdown } from "@/utils/markdown";
import type { ReportCard, RiskAssessmentResult } from "@/types/risk";
import type { AssistantResponseCard as AssistantResponseCardData } from "@/types/conversation";
import {
@ -29,7 +28,6 @@ const {
current: conversation,
sending: loading,
generating,
withdrawing,
error,
} = storeToRefs(conversationStore);
const withdrawingId = ref<number | null>(null);
@ -232,10 +230,7 @@ function closeTool() {
directResult.value = null;
}
async function submitDirectTool(
kind: "supplier" | "material" | "cases" | "solution",
query: string,
) {
async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solution", query: string) {
directLoading.value = true;
toolError.value = "";
try {
@ -243,8 +238,7 @@ async function submitDirectTool(
directResult.value = response.data.data ?? null;
if (response.data.data) {
directUserPrompt.value = query;
directResponseCard.value = response.data
.data as AssistantResponseCardData;
directResponseCard.value = response.data.data as AssistantResponseCardData;
closeTool();
await nextTick();
await scrollToBottom();
@ -252,9 +246,7 @@ async function submitDirectTool(
} catch (reason) {
toolError.value = axios.isAxiosError(reason)
? String(reason.response?.data?.message || "查询失败")
: reason instanceof Error
? reason.message
: "查询失败";
: reason instanceof Error ? reason.message : "查询失败";
} finally {
directLoading.value = false;
}
@ -306,19 +298,12 @@ async function submitTool() {
toolError.value = "请先填写必填内容";
return;
}
if (
["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")
) {
await submitDirectTool(
selectedTool as "supplier" | "material" | "cases" | "solution",
finalValue,
);
if (["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")) {
await submitDirectTool(selectedTool as "supplier" | "material" | "cases" | "solution", finalValue);
return;
}
closeTool();
void usePrompt(
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`,
);
void usePrompt(finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`);
}
function selectFile(event: Event) {
@ -355,6 +340,10 @@ function handleScroll() {
if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder();
}
function hasAssistantPair(index: number) {
return conversation.value?.messages[index + 1]?.role === "assistant";
}
function stopGeneration() {
conversationStore.stopGeneration();
}
@ -365,8 +354,6 @@ async function withdrawMessage(messageId: number) {
withdrawingId.value = messageId;
try {
await conversationStore.withdraw(messageId);
} catch {
// The store restores the message and exposes the failure in the page.
} finally {
withdrawingId.value = null;
}
@ -435,7 +422,9 @@ function refreshPrimaryReportState() {
const conversationId = conversation.value?.id;
primaryReportReady.value = Boolean(
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
? {
...cloneReport(result),
reportCards: storedCards.length
? storedCards
: (result.reportCards ?? []),
reportCards: storedCards.length ? storedCards : result.reportCards ?? [],
aiSolutionCards: stored.aiSolutionCards?.length
? stored.aiSolutionCards
: (result.aiSolutionCards ?? []),
: result.aiSolutionCards ?? [],
}
: cloneReport(result);
writeConversationReport(conversationId, merged, auth.user?.id);
@ -498,7 +485,7 @@ async function submit(
messageType: "text" | "image" | "file" | "audio" = "text",
) {
const value = prompt.value.trim();
if (!value || loading.value || withdrawing.value) return;
if (!value || loading.value) return;
prompt.value = "";
const sentConversation = await conversationStore.streamSend(
value,
@ -564,14 +551,18 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<time>{{ formatMessageTime(message.createdAt) }}</time>
<div class="user-message-actions">
<button
v-if="message.id > 0 && !generating"
type="button"
:disabled="
withdrawing || conversationStore.loadingOlder || loading
v-if="
message.id > 0 &&
!generating &&
hasAssistantPair(
conversation?.messages.indexOf(message) ?? -1,
)
"
type="button"
:disabled="withdrawingId !== null || loading"
@click="withdrawMessage(message.id)"
>
{{ withdrawingId === message.id ? "撤回中…" : "撤回" }}
撤回
</button>
</div>
</div>
@ -593,19 +584,12 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
>
</div>
<div
v-if="
message.content || isMessageThinking(message.id, messageIndex)
"
v-if="message.content || isMessageThinking(message.id, messageIndex)"
class="assistant-thinking"
>
<button
type="button"
class="assistant-thinking__header"
:disabled="!message.riskResult && !message.responseCard"
:aria-expanded="
(!message.riskResult && !message.responseCard) ||
expandedReplies.has(message.id)
"
@click="toggleReply(message.id)"
>
<span
@ -614,15 +598,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
>思考中</span
>
<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
>
</span>
<ChevronDown
v-if="message.riskResult || message.responseCard"
:size="13"
class="assistant-thinking__arrow"
:class="{ expanded: expandedReplies.has(message.id) }"
@ -633,16 +613,10 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
class="assistant-bubble"
:class="{
'assistant-bubble--collapsed':
(message.riskResult || message.responseCard) &&
!expandedReplies.has(message.id),
}"
>
<!-- eslint-disable vue/no-v-html -->
<div
class="assistant-markdown"
v-html="renderSafeMarkdown(message.content)"
/>
<!-- eslint-enable vue/no-v-html -->
{{ message.content }}
</div>
</div>
<RiskAnalysisCard
@ -667,9 +641,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<template v-if="directResponseCard">
<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>
<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">
{{ error }}
</p>
<p
v-if="withdrawing"
class="state-message"
role="status"
aria-live="polite"
>
正在撤回消息…
</p>
</div>
</div>
<AssistantComposer
ref="composer"
v-model="prompt"
:loading="loading || withdrawing"
:loading="loading"
:can-upload="auth.hasPermission('risk_item.submit')"
:can-review="auth.hasPermission('risk_item.review')"
@submit="submit"
@ -786,114 +750,39 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<p v-if="toolError" class="dialog-error">{{ toolError }}</p>
<section v-if="directResult" class="direct-result-card">
<template v-if="tool === 'supplier'">
<span class="direct-tier">{{
directResult.tier || "新供应商"
}}</span>
<span class="direct-tier">{{ directResult.tier || "新供应商" }}</span>
<div class="direct-lines">
<p>
近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b
>(代理合作频次口径)
</p>
<p>
基础分 <b>{{ directResult.supplierBaseScore ?? 70 }}</b> · α
<b
>×{{
Number(directResult.frequencyCoefficient ?? 1).toFixed(2)
}}</b
>
</p>
<p>
供应商得分 S供 =
<b>{{ directResult.supplierScore ?? 70 }}</b> 分
</p>
<p>近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b>(代理合作频次口径)</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>
<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 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>
</template>
<template v-else-if="tool === 'material'">
<strong>{{ directResult.title || "物料风险分析" }}</strong>
<p>
物料:<b>{{ directResult.material || directResult.query }}</b> ·
历史异常 {{ 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>物料:<b>{{ directResult.material || directResult.query }}</b> · 历史异常 {{ 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>
</template>
<template v-else-if="tool === 'cases'">
<strong>相似案例检索</strong>
<p>
关键词:{{ directResult.query }} · 匹配
{{ directResult.matchedCount ?? 0 }} 条
</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 || "异常描述未填写" }}
<p>关键词:{{ directResult.query }} · 匹配 {{ directResult.matchedCount ?? 0 }} 条</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>
</ul>
</template>
<template v-else>
<strong>{{ directResult.title || "AI 方案生成" }}</strong>
<p>
{{
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>
<p>{{ 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>
</template>
</section>
@ -901,11 +790,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<button type="button" class="dialog-secondary" @click="closeTool">
取消
</button>
<button
type="submit"
class="dialog-primary"
:disabled="directLoading"
>
<button type="submit" class="dialog-primary" :disabled="directLoading">
{{ toolSubmitLabel }}
</button>
</div>
@ -1049,91 +934,6 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
color: var(--color-text-secondary);
font-size: 13px;
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 {
@ -1144,13 +944,6 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
-webkit-line-clamp: 2;
}
.assistant-bubble--collapsed .assistant-markdown {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.assistant-thinking {
margin-bottom: 8px;
}
@ -1169,16 +962,10 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
font-size: 11px;
font-family: inherit;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
transition: background 0.15s, color 0.15s;
}
.assistant-thinking__header:disabled {
cursor: default;
}
.assistant-thinking__header:not(:disabled):hover {
.assistant-thinking__header:hover {
background: var(--color-bg-warm);
color: var(--color-text-secondary);
}

View File

@ -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);
});
});

View File

@ -29,11 +29,8 @@ export const useConversationStore = defineStore("conversation", () => {
};
const streamStates = reactive(new Map<number, StreamState>());
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 generating = sending;
const withdrawing = computed(() => Boolean(current.value && withdrawingConversations.has(current.value.id)));
let listRequest: Promise<void> | null = null;
let listRequestSearch: string | undefined;
let listLoadedAt = 0;
@ -68,12 +65,6 @@ export const useConversationStore = defineStore("conversation", () => {
}
async function open(id: number) {
const version = conversationVersions.get(id) ?? 0;
const pending = conversationCache.get(id);
if (withdrawingConversations.has(id) && pending) {
current.value = pending;
return;
}
loading.value = true;
error.value = null;
try {
@ -81,11 +72,6 @@ export const useConversationStore = defineStore("conversation", () => {
if (!response.data.data) throw new Error("对话响应为空");
const remote = response.data.data;
const cached = conversationCache.get(id);
// A GET started before withdrawal must not put the deleted turn back.
if ((conversationVersions.get(id) ?? 0) !== version && cached) {
current.value = cached;
return;
}
const stream = streamStates.get(id);
const merged = stream && cached
? {
@ -108,7 +94,6 @@ export const useConversationStore = defineStore("conversation", () => {
async function loadOlder() {
if (
loadingOlder.value ||
withdrawing.value ||
!current.value ||
!current.value.messages.length ||
!current.value.messagesTotal
@ -165,7 +150,6 @@ export const useConversationStore = defineStore("conversation", () => {
if (!current.value) await create();
if (!current.value) throw new Error("当前对话不存在");
const conversationId = current.value.id;
if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候");
plainSending.value = true;
error.value = null;
try {
@ -200,7 +184,6 @@ export const useConversationStore = defineStore("conversation", () => {
if (!current.value) await create();
if (!current.value) throw new Error("当前对话不存在");
const conversationId = current.value.id;
if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候");
if (streamStates.has(conversationId)) throw new Error("当前会话正在生成,请稍候");
const state: StreamState = {
controller: new AbortController(),
@ -404,55 +387,28 @@ export const useConversationStore = defineStore("conversation", () => {
async function withdraw(messageId: number) {
if (!current.value) return;
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,
});
loading.value = true;
error.value = null;
try {
const response = await withdrawConversationMessage(
conversationId,
current.value.id,
messageId,
);
if (!response.data.data) throw new Error("撤回响应为空");
const result = response.data.data;
// Keep already loaded older pages; the API returns only the newest page.
const messages = new Map(remaining.map((message) => [message.id, message]));
for (const message of result.messages) messages.set(message.id, message);
const mergedMessages = [...messages.values()].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt) || left.id - right.id,
current.value = response.data.data;
conversationCache.set(current.value.id, current.value);
const index = items.value.findIndex(
(item) => item.id === current.value?.id,
);
const merged = {
...result,
messages: mergedMessages,
hasMoreMessages: mergedMessages.length < result.messagesTotal,
};
if (conversationCache.has(conversationId)) setConversation(merged);
return merged;
const summary = toSummary(current.value);
if (index >= 0) items.value.splice(index, 1, summary);
else items.value.unshift(summary);
return current.value;
} 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 : "撤回失败";
throw reason;
} finally {
withdrawingConversations.delete(conversationId);
loading.value = false;
}
}
@ -477,7 +433,6 @@ export const useConversationStore = defineStore("conversation", () => {
loading,
sending,
generating,
withdrawing,
loadingOlder,
error,
loadList,

View File

@ -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("&lt;strong&gt;");
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("&lt;img");
expect(html).toContain("onerror=");
});
});

View File

@ -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,
});
}