diff --git a/src/api/conversations.ts b/src/api/conversations.ts
index d0e403a..bae5a19 100644
--- a/src/api/conversations.ts
+++ b/src/api/conversations.ts
@@ -40,28 +40,47 @@ export function sendConversationMessage(
);
}
+export function withdrawConversationMessage(
+ conversationId: number,
+ messageId: number,
+) {
+ return http.delete>(
+ `/v1/conversations/${conversationId}/messages/${messageId}`,
+ );
+}
+
export async function streamConversationMessage(
id: number,
content: string,
onEvent: (event: ConversationStreamEvent) => void,
messageType: "text" | "image" | "file" | "audio" = "text",
+ signal?: AbortSignal,
) {
const token = localStorage.getItem("risk_prediction_token");
const controller = new AbortController();
+ const abortFromCaller = () => controller.abort();
+ if (signal?.aborted) controller.abort();
+ signal?.addEventListener("abort", abortFromCaller, { once: true });
const timeout = window.setTimeout(() => controller.abort(), 180_000);
- const response = await fetch(
- `${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
- {
- method: "POST",
- headers: {
- Accept: "text/event-stream",
- "Content-Type": "application/json",
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ let response: Response;
+ try {
+ response = await fetch(
+ `${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "text/event-stream",
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({ content, messageType }),
+ signal: controller.signal,
},
- body: JSON.stringify({ content, messageType }),
- signal: controller.signal,
- },
- );
+ );
+ } catch (reason) {
+ if (signal?.aborted) throw new Error("已停止生成", { cause: reason });
+ throw reason;
+ }
if (response.status === 401) {
localStorage.removeItem("risk_prediction_token");
window.dispatchEvent(new CustomEvent("risk-auth-expired"));
@@ -109,11 +128,15 @@ export async function streamConversationMessage(
}
if (!completed) throw new Error("AI 流式连接中断,请重试");
} catch (reason) {
+ if (signal?.aborted) {
+ throw new Error("已停止生成", { cause: reason });
+ }
if (controller.signal.aborted) {
throw new Error("AI 响应超时,请稍后重试", { cause: reason });
}
throw reason;
} finally {
window.clearTimeout(timeout);
+ signal?.removeEventListener("abort", abortFromCaller);
}
}
diff --git a/src/components/assistant/AssistantResponseCard.vue b/src/components/assistant/AssistantResponseCard.vue
index 1e2ffa7..fc4b033 100644
--- a/src/components/assistant/AssistantResponseCard.vue
+++ b/src/components/assistant/AssistantResponseCard.vue
@@ -18,7 +18,9 @@ import type { AssistantResponseCard } from "@/types/conversation";
const props = defineProps<{ card: AssistantResponseCard }>();
const router = useRouter();
-const feedback = ref("");
+const emit = defineEmits<{
+ generateReport: [card: AssistantResponseCard];
+}>();
interface CardData extends AssistantResponseCard {
name?: string;
partyType?: string;
@@ -48,20 +50,36 @@ interface CardData extends AssistantResponseCard {
analysisBase?: string;
risks?: Array<{ level: string; title: string; detail: string }>;
similarCases?: Array<{
+ caseId?: number | null;
+ case_id?: number | null;
date?: string | null;
product?: string | null;
+ category?: string | null;
+ product_category?: string | null;
+ material?: string | null;
+ material_type?: string | null;
+ anomalyType?: string | null;
+ anomaly_type?: string | null;
problem?: string | null;
+ description?: string | null;
+ rootCause?: string | null;
+ root_cause?: string | null;
solution?: string | null;
+ result?: string | null;
+ defectLevel?: string | null;
+ defect_level?: string | null;
similarity?: number | null;
}>;
warning?: string;
}
const data = computed(() => props.card as CardData);
-
-function notify(label: string) {
- feedback.value = `${label}功能将在对应模块接入后执行`;
- window.setTimeout(() => (feedback.value = ""), 2400);
-}
+type SolutionCase = NonNullable[number];
+const selectedCase = ref(null);
+const standardCategories = computed(() =>
+ [...new Set((data.value.standards ?? []).map((item) => item.category))].join(
+ " / ",
+ ),
+);
function openSupplier() {
const supplier = data.value.name;
@@ -71,9 +89,38 @@ function openSupplier() {
});
}
+function openStandard(riskPoint: string) {
+ void router.push({
+ name: "standards",
+ query: { keyword: riskPoint.slice(0, 50) },
+ });
+}
+
+function openSimilarCase(item: SolutionCase) {
+ selectedCase.value = item;
+}
+
+function closeSimilarCase() {
+ selectedCase.value = null;
+}
+
+function openStandardLibrary() {
+ const firstStandard = data.value.standards?.[0];
+ void router.push({
+ name: "standards",
+ query: firstStandard?.riskPoint
+ ? { keyword: firstStandard.riskPoint.slice(0, 50) }
+ : undefined,
+ });
+}
+
function formatCount(value: unknown) {
return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据";
}
+
+function generateReport() {
+ emit("generateReport", props.card);
+}
@@ -230,50 +277,86 @@ function formatCount(value: unknown) {
标准兜底校验(无历史案例时启用)
- 关联标准校验
+ 标准判定 · 双引擎
-
+ 命中判定标准库 {{ data.standards?.length ?? 0 }} 条
+ · 覆盖 {{ standardCategories }}
+ · 来源:《标准文件.xlsx》219 条
+
+
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
+
+ 🔄
+ 双引擎融合:历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。
+
- 相似历史案例
- 匹配到 {{ data.similarCases.length }} 条相似案例
+
+
@@ -306,16 +389,25 @@ function formatCount(value: unknown) {
-
-
+
@@ -327,7 +419,104 @@ function formatCount(value: unknown) {
🏭 查供应商
- {{ feedback }}
+
+
+
+
+ {{ selectedCase.date || "日期未知" }}
+
+ 相似度 {{ Math.round(selectedCase.similarity * 100) }}%
+
+
+
+
+
- 品类
+ -
+ {{
+ selectedCase.category ||
+ selectedCase.product_category ||
+ "未填写"
+ }}
+
+
+
+
- 物料
+ -
+ {{
+ selectedCase.material || selectedCase.material_type || "未填写"
+ }}
+
+
+
+
- 异常类型
+ -
+ {{
+ selectedCase.anomalyType ||
+ selectedCase.anomaly_type ||
+ "未填写"
+ }}
+
+
+
+
- 缺陷等级
+ -
+ {{
+ selectedCase.defectLevel ||
+ selectedCase.defect_level ||
+ "未定级"
+ }}
+
+
+
+
- 问题描述
+ -
+ {{ selectedCase.problem || selectedCase.description || "未填写" }}
+
+
+
+
- 原因分析
+ - {{ selectedCase.rootCause || selectedCase.root_cause }}
+
+
+
- 解决方案
+ - {{ selectedCase.solution || "未填写" }}
+
+
+
- 处理结果
+ - {{ selectedCase.result }}
+
+
+
+
+
@@ -350,8 +539,19 @@ function formatCount(value: unknown) {
border-left: 3px solid var(--color-accent);
}
.response-card--solution {
- border: 1px solid #bfdbfe;
- border-left: 3px solid var(--color-purple);
+ position: relative;
+ overflow: hidden;
+ border: 1px solid var(--color-accent-soft);
+ border-radius: 10px;
+ padding: 22px;
+}
+.response-card--solution::before {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 3px;
+ border-radius: 10px 0 0 10px;
+ background: linear-gradient(180deg, var(--color-accent), var(--color-purple));
+ content: "";
}
.response-card__header {
display: flex;
@@ -376,7 +576,20 @@ function formatCount(value: unknown) {
color: var(--color-warning);
}
.response-card--solution .response-card__icon {
- color: var(--color-purple);
+ width: 28px;
+ height: 28px;
+ border-radius: 5px;
+ background: linear-gradient(135deg, var(--color-accent), var(--color-purple));
+ color: #fff;
+}
+.response-card--solution .response-card__header {
+ margin-bottom: 14px;
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+.response-card--solution .response-card__header h3 {
+ font-size: 14px;
+ font-weight: 650;
}
.response-card__header h3 {
margin: 0;
@@ -496,6 +709,66 @@ function formatCount(value: unknown) {
color: var(--color-warning);
font-size: 10px;
}
+.solution-standard-row {
+ display: flex;
+ width: 100%;
+ align-items: flex-start;
+ gap: 8px;
+ border: 0;
+ border-top: 1px dashed var(--color-border-light);
+ padding: 7px 0;
+ background: transparent;
+ color: var(--color-text-secondary);
+ font: inherit;
+ font-size: 13px;
+ line-height: 1.55;
+ text-align: left;
+ cursor: pointer;
+}
+.solution-standard-row:first-of-type {
+ border-top: 0;
+}
+.solution-standard-row:hover {
+ border-radius: 5px;
+ background: var(--color-bg-warm);
+}
+.solution-standard-tag {
+ flex: 0 0 auto;
+ border: 1px solid var(--color-accent-soft);
+ border-radius: 999px;
+ padding: 1px 8px;
+ background: var(--color-accent-subtle);
+ color: var(--color-accent);
+ font-size: 10px;
+ font-weight: 600;
+}
+.solution-standard-tag.law {
+ border-color: #fbcfe8;
+ background: #fce7f3;
+ color: #be185d;
+}
+.solution-standard-row > div {
+ min-width: 0;
+ flex: 1;
+}
+.solution-standard-row > div b {
+ color: var(--color-text-secondary);
+ font-weight: 500;
+}
+.solution-standard-row small {
+ display: block;
+ margin-top: 1px;
+ color: var(--color-text-faint);
+ font-size: 10px;
+}
+.solution-standard-row > strong {
+ flex: 0 0 auto;
+ color: var(--color-warning);
+ font-size: 11px;
+}
+.solution-standard-row > strong.ok {
+ color: var(--color-success);
+}
.suggestion-list {
margin: 12px 0 0;
padding-left: 18px;
@@ -540,13 +813,82 @@ function formatCount(value: unknown) {
}
.solution-base {
margin-top: 14px;
- border-radius: 6px;
- padding: 10px;
- background: #f3f7fb;
+ border-radius: 5px;
+ padding: 10px 12px;
+ background: var(--color-bg-warm);
color: var(--color-text-secondary);
- font-size: 11px;
+ font-size: 12px;
+ line-height: 1.7;
+}
+.solution-standards {
+ margin-bottom: 10px;
+ border: 1px solid #bfdbfe;
+ border-radius: 8px;
+ padding: 14px;
+ background: #fff;
+}
+.solution-standards h4 {
+ margin-bottom: 4px;
+ color: #1d4ed8;
+ font-size: 12px;
+}
+.solution-standards__sub {
+ margin-bottom: 10px;
+ color: var(--color-text-faint);
+ font-size: 10px;
+}
+.solution-standards__foot {
+ margin-top: 8px;
+ border-top: 1px dashed var(--color-border-light);
+ padding-top: 8px;
+ color: var(--color-text-muted);
+ font-size: 10px;
line-height: 1.6;
}
+.solution-standards__foot b {
+ color: var(--color-accent);
+}
+.solution-case-row {
+ display: grid;
+ width: 100%;
+ grid-template-columns: 58px minmax(0, 1fr);
+ align-items: start;
+ gap: 8px;
+ border: 0;
+ border-radius: 6px;
+ margin-bottom: 4px;
+ padding: 7px 9px;
+ background: var(--color-bg-warm);
+ color: var(--color-text-secondary);
+ text-align: left;
+ cursor: pointer;
+}
+.solution-case-row:hover {
+ background: #f0f0ee;
+}
+.solution-case-rank {
+ padding-top: 1px;
+ color: var(--color-accent);
+ font-size: 11px;
+ font-weight: 650;
+ white-space: nowrap;
+}
+.solution-case-row > div {
+ min-width: 0;
+}
+.solution-case-row small {
+ display: block;
+ color: var(--color-text-faint);
+ font-size: 9px;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+.solution-case-row em {
+ margin-left: 6px;
+ color: var(--color-accent);
+ font-style: normal;
+ white-space: nowrap;
+}
.solution-confidence {
margin-left: auto;
border: 1px solid #fcd34d;
@@ -559,12 +901,13 @@ function formatCount(value: unknown) {
}
.solution-suggest {
margin-top: 14px;
- border-radius: 6px;
- padding: 11px;
- background: #eff6ff;
+ border-radius: 5px;
+ padding: 12px;
+ background: var(--color-accent-subtle);
}
.solution-suggest h4 {
color: var(--color-accent);
+ font-size: 12px;
}
.solution-suggest ol {
margin: 0;
@@ -572,16 +915,23 @@ function formatCount(value: unknown) {
color: var(--color-text-secondary);
}
.solution-suggest li {
- margin: 4px 0;
- font-size: 11px;
- line-height: 1.55;
+ margin: 3px 0;
+ font-size: 13px;
+ line-height: 1.6;
}
.solution-risk {
display: flex;
gap: 7px;
- border-top: 1px solid var(--color-border-light);
+ border-bottom: 1px solid var(--color-border-light);
padding: 8px 0;
+ font-size: 13px;
+}
+.solution-risk:first-child {
+ border-top: 0;
+}
+.solution-risk:last-child {
+ border-bottom: 0;
}
.solution-risk > span {
flex: 0 0 auto;
@@ -602,24 +952,24 @@ function formatCount(value: unknown) {
.solution-risk p {
margin: 0;
color: var(--color-text-secondary);
- font-size: 11px;
- line-height: 1.5;
+ font-size: 13px;
+ line-height: 1.6;
}
.solution-warning {
display: flex;
gap: 6px;
margin-top: 10px;
border-radius: 5px;
- padding: 8px 10px;
+ padding: 8px 12px;
background: var(--color-warning-soft);
color: #92400e;
- font-size: 10px;
- line-height: 1.5;
+ font-size: 11px;
+ line-height: 1.6;
}
.response-actions {
display: flex;
flex-wrap: wrap;
- gap: 6px;
+ gap: 8px;
margin-top: 14px;
border-top: 1px solid var(--color-border-light);
padding-top: 12px;
@@ -628,27 +978,133 @@ function formatCount(value: unknown) {
display: inline-flex;
align-items: center;
gap: 5px;
- min-height: 30px;
+ min-height: 32px;
border: 1px solid var(--color-border);
border-radius: 5px;
- padding: 0 10px;
+ padding: 0 14px;
background: var(--color-surface);
color: var(--color-text-secondary);
- font-size: 10px;
+ font-size: 12px;
}
.response-actions__primary {
border-color: var(--color-text) !important;
background: var(--color-text) !important;
color: white !important;
}
-.response-feedback {
- margin: 8px 0 0;
+.response-actions button:disabled {
+ border-color: var(--color-border-light);
+ background: var(--color-bg-warm);
+ color: var(--color-text-faint);
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+.case-modal-backdrop {
+ position: fixed;
+ z-index: 30;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ background: rgb(17 17 17 / 28%);
+}
+.case-modal {
+ width: min(620px, 100%);
+ max-height: min(720px, calc(100vh - 48px));
+ overflow-y: auto;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ background: var(--color-surface);
+ box-shadow: 0 22px 54px rgb(15 23 42 / 22%);
+}
+.case-modal__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--color-border-light);
+ padding: 18px 20px;
+}
+.case-modal__header small {
color: var(--color-accent);
font-size: 10px;
+ font-weight: 700;
+}
+.case-modal__header h3 {
+ margin: 4px 0 0;
+ font-size: 17px;
+}
+.case-modal__close {
+ border: 0;
+ background: transparent;
+ color: var(--color-text-muted);
+ font-size: 22px;
+ cursor: pointer;
+}
+.case-modal__summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px 10px;
+}
+.case-modal__summary span {
+ border-radius: 4px;
+ padding: 4px 7px;
+ background: var(--color-accent-subtle);
+ color: var(--color-accent);
+ font-size: 11px;
+ font-weight: 700;
+}
+.case-modal__grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin: 0;
+ padding: 10px 20px 20px;
+}
+.case-modal__grid > div {
+ min-width: 0;
+ border: 1px solid var(--color-border-light);
+ border-radius: 5px;
+ padding: 9px 10px;
+}
+.case-modal__grid dt {
+ color: var(--color-text-muted);
+ font-size: 10px;
+}
+.case-modal__grid dd {
+ margin: 4px 0 0;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+ white-space: pre-wrap;
+}
+.case-modal__field--wide {
+ grid-column: 1 / -1;
+}
+.case-modal__footer {
+ border-top: 1px solid var(--color-border-light);
+ padding: 10px 20px 14px;
+ color: var(--color-text-faint);
+ font-size: 10px;
}
@media (max-width: 600px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
}
+@media (max-width: 560px) {
+ .case-modal-backdrop {
+ align-items: end;
+ padding: 0;
+ }
+ .case-modal {
+ max-height: 90vh;
+ border-radius: 10px 10px 0 0;
+ }
+ .case-modal__grid {
+ grid-template-columns: 1fr;
+ }
+ .case-modal__field--wide {
+ grid-column: auto;
+ }
+}
diff --git a/src/components/assistant/RiskAnalysisCard.vue b/src/components/assistant/RiskAnalysisCard.vue
index 9454001..2bf4500 100644
--- a/src/components/assistant/RiskAnalysisCard.vue
+++ b/src/components/assistant/RiskAnalysisCard.vue
@@ -437,7 +437,12 @@ function openSupplier() {
-
+
+ AI 案例推理方案明细(已追加)
+
+
+ {{ solution.title || "AI 案例推理生成" }}
+ 置信度:{{ solution.confidence }}
+
+
+ 分析基础:{{ solution.analysisBase }}
+
+
+ 风险项
+
+ -
+ {{ risk.level }} · {{ risk.title }}:{{ risk.detail }}
+
+
+
+
+ 建议方案
+
+ -
+ {{ suggestion }}
+
+
+
+
+ {{ solution.warning }}
+
+
+
+
七、标准判定结果
@@ -440,6 +481,61 @@ ol {
font-size: 13px;
line-height: 1.8;
}
+.ai-solution-report {
+ margin: 0 0 16px;
+ border: 1px solid #bfdbfe;
+ border-left: 3px solid #7c3aed;
+ border-radius: 7px;
+ padding: 14px;
+ background: #fafbff;
+}
+.report-subsection-title {
+ margin: 14px 0 8px;
+ color: #334155;
+ font-size: 13px;
+}
+.ai-solution-report header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+ color: #1d4ed8;
+ font-size: 13px;
+}
+.ai-solution-report header span {
+ border: 1px solid #fde68a;
+ border-radius: 999px;
+ padding: 2px 8px;
+ background: #fffbeb;
+ color: #d97706;
+ font-size: 10px;
+}
+.ai-solution-report p,
+.ai-solution-report li {
+ color: #334155;
+ font-size: 12px;
+ line-height: 1.7;
+}
+.ai-solution-report p {
+ margin: 6px 0;
+}
+.ai-solution-report h3 {
+ margin: 10px 0 4px;
+ font-size: 12px;
+}
+.ai-solution-report ul,
+.ai-solution-report ol {
+ margin: 4px 0;
+ padding-left: 20px;
+}
+.ai-solution-report__warning {
+ border-radius: 4px;
+ padding: 7px 9px;
+ background: #fffbeb;
+ color: #92400e !important;
+ font-size: 11px !important;
+}
@media (max-width: 700px) {
.report-page {
padding: 12px;
diff --git a/src/pages/RiskAssistantPage.vue b/src/pages/RiskAssistantPage.vue
index b4cae0c..dc030db 100644
--- a/src/pages/RiskAssistantPage.vue
+++ b/src/pages/RiskAssistantPage.vue
@@ -11,7 +11,7 @@ import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
import { useConversationStore } from "@/stores/conversation.store";
import { useAuthStore } from "@/stores/auth.store";
import { storeToRefs } from "pinia";
-import type { RiskAssessmentResult } from "@/types/risk";
+import type { AiSolutionReport, RiskAssessmentResult } from "@/types/risk";
const route = useRoute();
const router = useRouter();
@@ -20,8 +20,10 @@ const conversationStore = useConversationStore();
const {
current: conversation,
sending: loading,
+ generating,
error,
} = storeToRefs(conversationStore);
+const withdrawingId = ref(null);
const prompt = ref("");
const ocrOpen = ref(false);
const scrollArea = ref(null);
@@ -204,6 +206,25 @@ 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();
+}
+
+async function withdrawMessage(messageId: number) {
+ if (!conversation.value || withdrawingId.value !== null) return;
+ if (!window.confirm("撤回后将同时删除该问题和对应回答,确认撤回吗?")) return;
+ withdrawingId.value = messageId;
+ try {
+ await conversationStore.withdraw(messageId);
+ } finally {
+ withdrawingId.value = null;
+ }
+}
+
async function openFromRoute() {
const rawId = route.query.conversation;
if (typeof rawId !== "string") return;
@@ -227,6 +248,53 @@ function openReport(result: RiskAssessmentResult) {
void router.push({ name: "report" });
}
+function openSolutionReport(card: Record) {
+ const solution = card as AiSolutionReport;
+ const current = conversation.value?.messages
+ .slice()
+ .reverse()
+ .find((message) => message.riskResult)?.riskResult;
+ let stored: RiskAssessmentResult | null = null;
+ try {
+ const raw = sessionStorage.getItem("dieya-risk-report");
+ stored = raw ? (JSON.parse(raw) as RiskAssessmentResult) : null;
+ } catch {
+ stored = null;
+ }
+ const sameReport = Boolean(
+ current && stored && current.subjectId === stored.subjectId,
+ );
+ const base: RiskAssessmentResult =
+ sameReport && stored
+ ? structuredClone(stored)
+ : current
+ ? structuredClone(current)
+ : stored || {
+ subjectId: "AI 案例推理方案",
+ score: 50,
+ level: "medium",
+ modelVersion: "ai-solution-card",
+ factors: [],
+ };
+ const existing = base.aiSolutionCards ?? [];
+ const merged: RiskAssessmentResult = {
+ ...base,
+ aiSolutionCards: [...existing, solution],
+ aiSuggestions: [
+ ...(base.aiSuggestions ?? []),
+ ...(solution.suggestions ?? []),
+ ],
+ similarCases: solution.similarCases?.length
+ ? [...(base.similarCases ?? []), ...solution.similarCases]
+ : base.similarCases,
+ standardJudgment: solution.standards?.length
+ ? [...(base.standardJudgment ?? []), ...solution.standards]
+ : base.standardJudgment,
+ };
+ sessionStorage.setItem("dieya-risk-report", JSON.stringify(merged));
+ void router.push({ name: "report" });
+}
+
async function submit(
messageType: "text" | "image" | "file" | "audio" = "text",
) {
@@ -293,6 +361,22 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
{{ message.content }}
+
+
+ 撤回
+
+
@@ -517,6 +609,45 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
text-align: right;
}
+.user-message-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 7px;
+}
+
+.user-message-actions button {
+ border-color: rgb(255 255 255 / 35%);
+ color: rgb(255 255 255 / 82%);
+}
+
+.user-message-actions button,
+.stop-generation {
+ border: 1px solid rgb(255 255 255 / 35%);
+ border-radius: 999px;
+ padding: 3px 9px;
+ background: transparent;
+ color: rgb(255 255 255 / 82%);
+ font-size: 10px;
+}
+
+.user-message-actions button:hover,
+.stop-generation:hover {
+ border-color: rgb(255 255 255 / 65%);
+ color: white;
+}
+
+.user-message-actions button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.stop-generation {
+ margin-top: 8px;
+ border-color: var(--color-border-strong);
+ background: var(--color-surface);
+ color: var(--color-text-secondary);
+}
+
.assistant-bubble {
width: fit-content;
max-width: 82%;
diff --git a/src/stores/conversation.store.ts b/src/stores/conversation.store.ts
index 9def815..0b6eeda 100644
--- a/src/stores/conversation.store.ts
+++ b/src/stores/conversation.store.ts
@@ -5,6 +5,7 @@ import {
createConversation,
fetchConversation,
fetchConversations,
+ withdrawConversationMessage,
streamConversationMessage,
sendConversationMessage,
} from "@/api/conversations";
@@ -19,6 +20,9 @@ export const useConversationStore = defineStore("conversation", () => {
const sending = ref(false);
const loadingOlder = ref(false);
const error = ref(null);
+ const generating = ref(false);
+ let activeController: AbortController | null = null;
+ let activeUserMessageId: number | null = null;
let listRequest: Promise | null = null;
let listRequestSearch: string | undefined;
let listLoadedAt = 0;
@@ -144,6 +148,8 @@ export const useConversationStore = defineStore("conversation", () => {
if (!current.value) await create();
if (!current.value) throw new Error("当前对话不存在");
sending.value = true;
+ generating.value = true;
+ activeController = new AbortController();
error.value = null;
const startedAt = new Date().toISOString();
const userMessage = {
@@ -156,6 +162,7 @@ export const useConversationStore = defineStore("conversation", () => {
semantic: null,
createdAt: startedAt,
};
+ activeUserMessageId = userMessage.id;
const assistantMessage = {
id: userMessage.id - 1,
role: "assistant" as const,
@@ -178,6 +185,23 @@ export const useConversationStore = defineStore("conversation", () => {
conversationId,
content,
(event) => {
+ if (
+ event.type === "start" &&
+ event.data &&
+ typeof event.data === "object" &&
+ "messageId" in event.data &&
+ typeof event.data.messageId === "number" &&
+ current.value
+ ) {
+ const serverMessageId = event.data.messageId;
+ const messages = current.value.messages.map((message) =>
+ message.id === userMessage.id
+ ? { ...message, id: serverMessageId }
+ : message,
+ );
+ activeUserMessageId = serverMessageId;
+ current.value = { ...current.value, messages };
+ }
if (event.type === "delta" && event.content) onDelta(event.content);
if (event.type === "semantic" && event.data && current.value) {
const messages = [...current.value.messages];
@@ -207,6 +231,7 @@ export const useConversationStore = defineStore("conversation", () => {
current.value = event.data as Conversation;
},
messageType,
+ activeController.signal,
);
if (
!current.value ||
@@ -223,15 +248,49 @@ export const useConversationStore = defineStore("conversation", () => {
return current.value;
} catch (reason) {
const conversationId = current.value?.id;
+ if (activeController?.signal.aborted) {
+ if (conversationId) {
+ try {
+ const response = await fetchConversation(conversationId);
+ if (response.data.data) current.value = response.data.data;
+ } catch {
+ // If the request was cancelled before persistence, remove both
+ // temporary messages from the local conversation.
+ if (current.value) {
+ const messages = current.value.messages.filter(
+ (message) =>
+ message.id !== assistantMessage.id &&
+ message.id !== userMessage.id,
+ );
+ current.value = {
+ ...current.value,
+ messages,
+ messageCount: Math.max(0, current.value.messageCount - 2),
+ messagesTotal: Math.max(0, current.value.messagesTotal - 2),
+ };
+ }
+ }
+ const summary = current.value ? toSummary(current.value) : null;
+ if (summary) {
+ const index = items.value.findIndex(
+ (item) => item.id === summary.id,
+ );
+ if (index >= 0) items.value.splice(index, 1, summary);
+ else items.value.unshift(summary);
+ }
+ }
+ return current.value;
+ }
if (current.value) {
const messages = current.value.messages.filter(
- (message) => message.id !== assistantMessage.id,
+ (message) =>
+ message.id !== assistantMessage.id && message.id !== userMessage.id,
);
current.value = {
...current.value,
messages,
- messageCount: Math.max(0, current.value.messageCount - 1),
- messagesTotal: Math.max(0, current.value.messagesTotal - 1),
+ messageCount: Math.max(0, current.value.messageCount - 2),
+ messagesTotal: Math.max(0, current.value.messagesTotal - 2),
};
}
if (conversationId) {
@@ -246,6 +305,43 @@ export const useConversationStore = defineStore("conversation", () => {
throw reason;
} finally {
sending.value = false;
+ generating.value = false;
+ activeController = null;
+ activeUserMessageId = null;
+ }
+ }
+
+ function stopGeneration() {
+ activeController?.abort();
+ }
+
+ function isGeneratingMessage(messageId: number) {
+ return activeUserMessageId === messageId && generating.value;
+ }
+
+ async function withdraw(messageId: number) {
+ if (!current.value) return;
+ loading.value = true;
+ error.value = null;
+ try {
+ const response = await withdrawConversationMessage(
+ current.value.id,
+ messageId,
+ );
+ if (!response.data.data) throw new Error("撤回响应为空");
+ current.value = response.data.data;
+ const index = items.value.findIndex(
+ (item) => item.id === current.value?.id,
+ );
+ const summary = toSummary(current.value);
+ if (index >= 0) items.value.splice(index, 1, summary);
+ else items.value.unshift(summary);
+ return current.value;
+ } catch (reason) {
+ error.value = reason instanceof Error ? reason.message : "撤回失败";
+ throw reason;
+ } finally {
+ loading.value = false;
}
}
@@ -269,6 +365,7 @@ export const useConversationStore = defineStore("conversation", () => {
current,
loading,
sending,
+ generating,
loadingOlder,
error,
loadList,
@@ -277,6 +374,9 @@ export const useConversationStore = defineStore("conversation", () => {
create,
send,
streamSend,
+ stopGeneration,
+ isGeneratingMessage,
+ withdraw,
reset,
};
});
diff --git a/src/types/risk.ts b/src/types/risk.ts
index 13d9681..31e06cd 100644
--- a/src/types/risk.ts
+++ b/src/types/risk.ts
@@ -53,6 +53,7 @@ export interface RiskAssessmentResult {
similarity?: number | null;
}>;
aiSuggestions?: string[];
+ aiSolutionCards?: AiSolutionReport[];
riskBadge?: string | null;
alertText?: string | null;
categoryWarning?: {
@@ -84,6 +85,24 @@ export interface RiskAssessmentResult {
dimensionScores?: RiskDimensionScore[];
}
+export interface AiSolutionReport {
+ title?: string;
+ confidence?: string | null;
+ analysisBase?: string | null;
+ standardFallbackEnabled?: boolean;
+ standards?: Array<{
+ category: string;
+ riskPoint: string;
+ rule: string;
+ source: string | null;
+ status: string;
+ }>;
+ similarCases?: NonNullable;
+ risks?: Array<{ level: string; title: string; detail: string }>;
+ suggestions?: string[];
+ warning?: string | null;
+}
+
export type RiskReportResult = RiskAssessmentResult;
export interface ModelInfo {