完善 AI 卡片交互与会话消息控制
实现 AI 案例推理方案生成报告并追加到当前会话报告;查看标准详情跳转标准库;优化标准兜底和相似案例展示;新增停止生成、撤回问题及对应回答功能,并将未接入按钮置灰隐藏。
This commit is contained in:
parent
74a1c9d4bc
commit
69a1433a86
@ -40,28 +40,47 @@ export function sendConversationMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withdrawConversationMessage(
|
||||||
|
conversationId: number,
|
||||||
|
messageId: number,
|
||||||
|
) {
|
||||||
|
return http.delete<ApiResponse<Conversation>>(
|
||||||
|
`/v1/conversations/${conversationId}/messages/${messageId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function streamConversationMessage(
|
export async function streamConversationMessage(
|
||||||
id: number,
|
id: number,
|
||||||
content: string,
|
content: string,
|
||||||
onEvent: (event: ConversationStreamEvent) => void,
|
onEvent: (event: ConversationStreamEvent) => void,
|
||||||
messageType: "text" | "image" | "file" | "audio" = "text",
|
messageType: "text" | "image" | "file" | "audio" = "text",
|
||||||
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const token = localStorage.getItem("risk_prediction_token");
|
const token = localStorage.getItem("risk_prediction_token");
|
||||||
const controller = new AbortController();
|
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 timeout = window.setTimeout(() => controller.abort(), 180_000);
|
||||||
const response = await fetch(
|
let response: Response;
|
||||||
`${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
|
try {
|
||||||
{
|
response = await fetch(
|
||||||
method: "POST",
|
`${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
|
||||||
headers: {
|
{
|
||||||
Accept: "text/event-stream",
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
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) {
|
if (response.status === 401) {
|
||||||
localStorage.removeItem("risk_prediction_token");
|
localStorage.removeItem("risk_prediction_token");
|
||||||
window.dispatchEvent(new CustomEvent("risk-auth-expired"));
|
window.dispatchEvent(new CustomEvent("risk-auth-expired"));
|
||||||
@ -109,11 +128,15 @@ export async function streamConversationMessage(
|
|||||||
}
|
}
|
||||||
if (!completed) throw new Error("AI 流式连接中断,请重试");
|
if (!completed) throw new Error("AI 流式连接中断,请重试");
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("已停止生成", { cause: reason });
|
||||||
|
}
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
throw new Error("AI 响应超时,请稍后重试", { cause: reason });
|
throw new Error("AI 响应超时,请稍后重试", { cause: reason });
|
||||||
}
|
}
|
||||||
throw reason;
|
throw reason;
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener("abort", abortFromCaller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,9 @@ import type { AssistantResponseCard } from "@/types/conversation";
|
|||||||
|
|
||||||
const props = defineProps<{ card: AssistantResponseCard }>();
|
const props = defineProps<{ card: AssistantResponseCard }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const feedback = ref("");
|
const emit = defineEmits<{
|
||||||
|
generateReport: [card: AssistantResponseCard];
|
||||||
|
}>();
|
||||||
interface CardData extends AssistantResponseCard {
|
interface CardData extends AssistantResponseCard {
|
||||||
name?: string;
|
name?: string;
|
||||||
partyType?: string;
|
partyType?: string;
|
||||||
@ -48,20 +50,36 @@ interface CardData extends AssistantResponseCard {
|
|||||||
analysisBase?: string;
|
analysisBase?: string;
|
||||||
risks?: Array<{ level: string; title: string; detail: string }>;
|
risks?: Array<{ level: string; title: string; detail: string }>;
|
||||||
similarCases?: Array<{
|
similarCases?: Array<{
|
||||||
|
caseId?: number | null;
|
||||||
|
case_id?: number | null;
|
||||||
date?: string | null;
|
date?: string | null;
|
||||||
product?: 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;
|
problem?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
rootCause?: string | null;
|
||||||
|
root_cause?: string | null;
|
||||||
solution?: string | null;
|
solution?: string | null;
|
||||||
|
result?: string | null;
|
||||||
|
defectLevel?: string | null;
|
||||||
|
defect_level?: string | null;
|
||||||
similarity?: number | null;
|
similarity?: number | null;
|
||||||
}>;
|
}>;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
}
|
}
|
||||||
const data = computed(() => props.card as CardData);
|
const data = computed(() => props.card as CardData);
|
||||||
|
type SolutionCase = NonNullable<CardData["similarCases"]>[number];
|
||||||
function notify(label: string) {
|
const selectedCase = ref<SolutionCase | null>(null);
|
||||||
feedback.value = `${label}功能将在对应模块接入后执行`;
|
const standardCategories = computed(() =>
|
||||||
window.setTimeout(() => (feedback.value = ""), 2400);
|
[...new Set((data.value.standards ?? []).map((item) => item.category))].join(
|
||||||
}
|
" / ",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
function openSupplier() {
|
function openSupplier() {
|
||||||
const supplier = data.value.name;
|
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) {
|
function formatCount(value: unknown) {
|
||||||
return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据";
|
return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateReport() {
|
||||||
|
emit("generateReport", props.card);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -230,50 +277,86 @@ function formatCount(value: unknown) {
|
|||||||
</div>
|
</div>
|
||||||
<section
|
<section
|
||||||
v-if="data.standardFallbackEnabled || data.standards?.length"
|
v-if="data.standardFallbackEnabled || data.standards?.length"
|
||||||
class="solution-section"
|
class="solution-section solution-standards"
|
||||||
>
|
>
|
||||||
<h4>
|
<h4>
|
||||||
<Ruler :size="14" />
|
<Ruler :size="14" />
|
||||||
<span v-if="data.standardFallbackEnabled"
|
<span v-if="data.standardFallbackEnabled"
|
||||||
>标准兜底校验(无历史案例时启用)</span
|
>标准兜底校验(无历史案例时启用)</span
|
||||||
>
|
>
|
||||||
<span v-else>关联标准校验</span>
|
<span v-else>标准判定 · 双引擎</span>
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div class="solution-standards__sub">
|
||||||
|
命中判定标准库 {{ data.standards?.length ?? 0 }} 条<span
|
||||||
|
v-if="standardCategories"
|
||||||
|
>
|
||||||
|
· 覆盖 {{ standardCategories }}</span
|
||||||
|
>
|
||||||
|
· 来源:《标准文件.xlsx》219 条
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
v-for="standard in data.standards"
|
v-for="standard in data.standards"
|
||||||
:key="`${standard.category}-${standard.riskPoint}`"
|
:key="`${standard.category}-${standard.riskPoint}`"
|
||||||
class="standard-row"
|
class="solution-standard-row"
|
||||||
|
type="button"
|
||||||
|
title="查看标准库详情"
|
||||||
|
@click="openStandard(standard.riskPoint)"
|
||||||
>
|
>
|
||||||
<span>{{ standard.category }}</span>
|
<span
|
||||||
|
class="solution-standard-tag"
|
||||||
|
:class="{
|
||||||
|
law: standard.category === '法规' || standard.category === '包装',
|
||||||
|
}"
|
||||||
|
>{{ standard.category }}</span
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<b>{{ standard.riskPoint }}</b
|
<b>{{ standard.riskPoint }}</b
|
||||||
><small>{{ standard.rule }} · {{ standard.source }}</small>
|
><small
|
||||||
|
>{{ standard.rule }} ·
|
||||||
|
{{ standard.source || "标准文件.xlsx" }}</small
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<strong>{{ standard.status }}</strong>
|
<strong
|
||||||
</div>
|
:class="{
|
||||||
|
ok: standard.status === '合规' || standard.status === '打样必测',
|
||||||
|
}"
|
||||||
|
>{{ standard.status }}</strong
|
||||||
|
>
|
||||||
|
</button>
|
||||||
<p v-if="!data.standards?.length" class="empty">
|
<p v-if="!data.standards?.length" class="empty">
|
||||||
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
|
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="data.standardFallbackEnabled && data.standards?.length"
|
||||||
|
class="solution-standards__foot"
|
||||||
|
>
|
||||||
|
🔄
|
||||||
|
双引擎融合:历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="data.similarCases?.length" class="solution-section">
|
<section v-if="data.similarCases?.length" class="solution-section">
|
||||||
<h4>相似历史案例</h4>
|
<h4>匹配到 {{ data.similarCases.length }} 条相似案例</h4>
|
||||||
<div
|
<button
|
||||||
v-for="(item, index) in data.similarCases"
|
v-for="(item, index) in data.similarCases"
|
||||||
:key="`${item.date}-${index}`"
|
:key="`${item.date}-${index}`"
|
||||||
class="standard-row"
|
class="solution-case-row"
|
||||||
|
type="button"
|
||||||
|
title="查看案例详情"
|
||||||
|
@click="openSimilarCase(item)"
|
||||||
>
|
>
|
||||||
<span>TOP {{ index + 1 }}</span>
|
<span class="solution-case-rank">TOP {{ index + 1 }}</span>
|
||||||
<div>
|
<div>
|
||||||
<b>{{ item.product || "未命名产品" }}</b>
|
|
||||||
<small
|
<small
|
||||||
>{{ item.problem || "异常问题未填写" }} →
|
>{{ item.date || "日期未知" }} ·
|
||||||
|
{{ item.product || "未命名产品" }} ·
|
||||||
|
{{ item.problem || "异常问题未填写" }} →
|
||||||
{{ item.solution || "解决方案未填写" }}
|
{{ item.solution || "解决方案未填写" }}
|
||||||
<em v-if="item.similarity != null"
|
<em v-if="item.similarity != null"
|
||||||
>相似度 {{ Math.round(item.similarity * 100) }}%</em
|
>相似度 {{ Math.round(item.similarity * 100) }}%</em
|
||||||
></small
|
></small
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="data.risks?.length" class="solution-section risk-list">
|
<section v-if="data.risks?.length" class="solution-section risk-list">
|
||||||
<div v-for="risk in data.risks" :key="risk.title" class="solution-risk">
|
<div v-for="risk in data.risks" :key="risk.title" class="solution-risk">
|
||||||
@ -306,16 +389,25 @@ function formatCount(value: unknown) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="response-actions__primary"
|
class="response-actions__primary"
|
||||||
@click="notify('生成报告')"
|
:disabled="card.type !== 'solution'"
|
||||||
|
:title="card.type === 'solution' ? '追加到当前风险报告' : '功能开发中'"
|
||||||
|
@click="generateReport"
|
||||||
>
|
>
|
||||||
<CheckCircle2 :size="14" />生成报告
|
<CheckCircle2 :size="14" />生成报告
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="notify('查看详情')">查看详情</button>
|
<button
|
||||||
<button type="button" @click="notify('追问详情')">追问详情</button>
|
type="button"
|
||||||
|
:disabled="card.type !== 'solution'"
|
||||||
|
:title="card.type === 'solution' ? '跳转判定标准库' : '功能开发中'"
|
||||||
|
@click="openStandardLibrary"
|
||||||
|
>
|
||||||
|
查看标准详情
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="card.type === 'solution'"
|
v-if="card.type === 'solution'"
|
||||||
type="button"
|
type="button"
|
||||||
@click="notify('收藏方案')"
|
disabled
|
||||||
|
title="功能开发中"
|
||||||
>
|
>
|
||||||
📌 收藏方案
|
📌 收藏方案
|
||||||
</button>
|
</button>
|
||||||
@ -327,7 +419,104 @@ function formatCount(value: unknown) {
|
|||||||
🏭 查供应商
|
🏭 查供应商
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="feedback" class="response-feedback">{{ feedback }}</p>
|
<div
|
||||||
|
v-if="selectedCase"
|
||||||
|
class="case-modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
@click.self="closeSimilarCase"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="case-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="solution-case-title"
|
||||||
|
>
|
||||||
|
<header class="case-modal__header">
|
||||||
|
<div>
|
||||||
|
<small>相似历史案例</small>
|
||||||
|
<h3 id="solution-case-title">
|
||||||
|
{{ selectedCase.product || "未命名产品" }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="case-modal__close"
|
||||||
|
aria-label="关闭案例详情"
|
||||||
|
@click="closeSimilarCase"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="case-modal__summary">
|
||||||
|
<strong>{{ selectedCase.date || "日期未知" }}</strong>
|
||||||
|
<span v-if="selectedCase.similarity != null">
|
||||||
|
相似度 {{ Math.round(selectedCase.similarity * 100) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl class="case-modal__grid">
|
||||||
|
<div>
|
||||||
|
<dt>品类</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
selectedCase.category ||
|
||||||
|
selectedCase.product_category ||
|
||||||
|
"未填写"
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>物料</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
selectedCase.material || selectedCase.material_type || "未填写"
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>异常类型</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
selectedCase.anomalyType ||
|
||||||
|
selectedCase.anomaly_type ||
|
||||||
|
"未填写"
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>缺陷等级</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
selectedCase.defectLevel ||
|
||||||
|
selectedCase.defect_level ||
|
||||||
|
"未定级"
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="case-modal__field--wide">
|
||||||
|
<dt>问题描述</dt>
|
||||||
|
<dd>
|
||||||
|
{{ selectedCase.problem || selectedCase.description || "未填写" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedCase.rootCause || selectedCase.root_cause"
|
||||||
|
class="case-modal__field--wide"
|
||||||
|
>
|
||||||
|
<dt>原因分析</dt>
|
||||||
|
<dd>{{ selectedCase.rootCause || selectedCase.root_cause }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="case-modal__field--wide">
|
||||||
|
<dt>解决方案</dt>
|
||||||
|
<dd>{{ selectedCase.solution || "未填写" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedCase.result" class="case-modal__field--wide">
|
||||||
|
<dt>处理结果</dt>
|
||||||
|
<dd>{{ selectedCase.result }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<footer class="case-modal__footer">点击遮罩或右上角关闭详情</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -350,8 +539,19 @@ function formatCount(value: unknown) {
|
|||||||
border-left: 3px solid var(--color-accent);
|
border-left: 3px solid var(--color-accent);
|
||||||
}
|
}
|
||||||
.response-card--solution {
|
.response-card--solution {
|
||||||
border: 1px solid #bfdbfe;
|
position: relative;
|
||||||
border-left: 3px solid var(--color-purple);
|
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 {
|
.response-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -376,7 +576,20 @@ function formatCount(value: unknown) {
|
|||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
.response-card--solution .response-card__icon {
|
.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 {
|
.response-card__header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -496,6 +709,66 @@ function formatCount(value: unknown) {
|
|||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
font-size: 10px;
|
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 {
|
.suggestion-list {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
@ -540,13 +813,82 @@ function formatCount(value: unknown) {
|
|||||||
}
|
}
|
||||||
.solution-base {
|
.solution-base {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
border-radius: 6px;
|
border-radius: 5px;
|
||||||
padding: 10px;
|
padding: 10px 12px;
|
||||||
background: #f3f7fb;
|
background: var(--color-bg-warm);
|
||||||
color: var(--color-text-secondary);
|
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;
|
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 {
|
.solution-confidence {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
border: 1px solid #fcd34d;
|
border: 1px solid #fcd34d;
|
||||||
@ -559,12 +901,13 @@ function formatCount(value: unknown) {
|
|||||||
}
|
}
|
||||||
.solution-suggest {
|
.solution-suggest {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
border-radius: 6px;
|
border-radius: 5px;
|
||||||
padding: 11px;
|
padding: 12px;
|
||||||
background: #eff6ff;
|
background: var(--color-accent-subtle);
|
||||||
}
|
}
|
||||||
.solution-suggest h4 {
|
.solution-suggest h4 {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.solution-suggest ol {
|
.solution-suggest ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -572,16 +915,23 @@ function formatCount(value: unknown) {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
.solution-suggest li {
|
.solution-suggest li {
|
||||||
margin: 4px 0;
|
margin: 3px 0;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.solution-risk {
|
.solution-risk {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
border-top: 1px solid var(--color-border-light);
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.solution-risk:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.solution-risk:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
.solution-risk > span {
|
.solution-risk > span {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@ -602,24 +952,24 @@ function formatCount(value: unknown) {
|
|||||||
.solution-risk p {
|
.solution-risk p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.solution-warning {
|
.solution-warning {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 8px 10px;
|
padding: 8px 12px;
|
||||||
background: var(--color-warning-soft);
|
background: var(--color-warning-soft);
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.response-actions {
|
.response-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-border-light);
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
@ -628,27 +978,133 @@ function formatCount(value: unknown) {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
min-height: 30px;
|
min-height: 32px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0 10px;
|
padding: 0 14px;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.response-actions__primary {
|
.response-actions__primary {
|
||||||
border-color: var(--color-text) !important;
|
border-color: var(--color-text) !important;
|
||||||
background: var(--color-text) !important;
|
background: var(--color-text) !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
.response-feedback {
|
.response-actions button:disabled {
|
||||||
margin: 8px 0 0;
|
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);
|
color: var(--color-accent);
|
||||||
font-size: 10px;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -437,7 +437,12 @@ function openSupplier() {
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" @click="openCase()">案例详情</button>
|
<button type="button" @click="openCase()">案例详情</button>
|
||||||
<button type="button" @click="openSupplier">供应商对比</button>
|
<button type="button" @click="openSupplier">供应商对比</button>
|
||||||
<button v-if="canMonitor" type="button" @click="notify('加入监控')">
|
<button
|
||||||
|
v-if="canMonitor"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
title="加入监控功能开发中"
|
||||||
|
>
|
||||||
加入监控
|
加入监控
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1117,6 +1122,13 @@ h3 {
|
|||||||
background: var(--color-text);
|
background: var(--color-text);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
.risk-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;
|
||||||
|
}
|
||||||
|
|
||||||
.risk-feedback {
|
.risk-feedback {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
|
|||||||
@ -277,6 +277,47 @@ function dimensionConclusion(item: {
|
|||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<template v-if="report.aiSolutionCards?.length">
|
||||||
|
<h3 class="report-subsection-title">AI 案例推理方案明细(已追加)</h3>
|
||||||
|
<section
|
||||||
|
v-for="(solution, index) in report.aiSolutionCards"
|
||||||
|
:key="`${solution.title || 'solution'}-${index}`"
|
||||||
|
class="ai-solution-report"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<strong>{{ solution.title || "AI 案例推理生成" }}</strong>
|
||||||
|
<span v-if="solution.confidence"
|
||||||
|
>置信度:{{ solution.confidence }}</span
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<p v-if="solution.analysisBase">
|
||||||
|
<b>分析基础:</b>{{ solution.analysisBase }}
|
||||||
|
</p>
|
||||||
|
<template v-if="solution.risks?.length">
|
||||||
|
<h3>风险项</h3>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="risk in solution.risks"
|
||||||
|
:key="`${risk.title}-${risk.detail}`"
|
||||||
|
>
|
||||||
|
<b>{{ risk.level }} · {{ risk.title }}:</b>{{ risk.detail }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<template v-if="solution.suggestions?.length">
|
||||||
|
<h3>建议方案</h3>
|
||||||
|
<ol>
|
||||||
|
<li v-for="suggestion in solution.suggestions" :key="suggestion">
|
||||||
|
{{ suggestion }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
|
<p v-if="solution.warning" class="ai-solution-report__warning">
|
||||||
|
{{ solution.warning }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<h2>七、标准判定结果</h2>
|
<h2>七、标准判定结果</h2>
|
||||||
<template v-if="report.standardJudgment?.length">
|
<template v-if="report.standardJudgment?.length">
|
||||||
<table>
|
<table>
|
||||||
@ -440,6 +481,61 @@ ol {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.8;
|
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) {
|
@media (max-width: 700px) {
|
||||||
.report-page {
|
.report-page {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
|
|||||||
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 type { RiskAssessmentResult } from "@/types/risk";
|
import type { AiSolutionReport, RiskAssessmentResult } from "@/types/risk";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -20,8 +20,10 @@ const conversationStore = useConversationStore();
|
|||||||
const {
|
const {
|
||||||
current: conversation,
|
current: conversation,
|
||||||
sending: loading,
|
sending: loading,
|
||||||
|
generating,
|
||||||
error,
|
error,
|
||||||
} = storeToRefs(conversationStore);
|
} = storeToRefs(conversationStore);
|
||||||
|
const withdrawingId = ref<number | null>(null);
|
||||||
const prompt = ref("");
|
const prompt = ref("");
|
||||||
const ocrOpen = ref(false);
|
const ocrOpen = ref(false);
|
||||||
const scrollArea = ref<HTMLElement | null>(null);
|
const scrollArea = ref<HTMLElement | null>(null);
|
||||||
@ -204,6 +206,25 @@ 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() {
|
||||||
|
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() {
|
async function openFromRoute() {
|
||||||
const rawId = route.query.conversation;
|
const rawId = route.query.conversation;
|
||||||
if (typeof rawId !== "string") return;
|
if (typeof rawId !== "string") return;
|
||||||
@ -227,6 +248,53 @@ function openReport(result: RiskAssessmentResult) {
|
|||||||
void router.push({ name: "report" });
|
void router.push({ name: "report" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSolutionReport(card: Record<string, unknown>) {
|
||||||
|
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(
|
async function submit(
|
||||||
messageType: "text" | "image" | "file" | "audio" = "text",
|
messageType: "text" | "image" | "file" | "audio" = "text",
|
||||||
) {
|
) {
|
||||||
@ -293,6 +361,22 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<div v-if="message.role === 'user'" class="user-bubble">
|
<div v-if="message.role === 'user'" class="user-bubble">
|
||||||
{{ message.content }}
|
{{ message.content }}
|
||||||
<time>{{ formatMessageTime(message.createdAt) }}</time>
|
<time>{{ formatMessageTime(message.createdAt) }}</time>
|
||||||
|
<div class="user-message-actions">
|
||||||
|
<button
|
||||||
|
v-if="
|
||||||
|
message.id > 0 &&
|
||||||
|
!generating &&
|
||||||
|
hasAssistantPair(
|
||||||
|
conversation?.messages.indexOf(message) ?? -1,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
type="button"
|
||||||
|
:disabled="withdrawingId !== null || loading"
|
||||||
|
@click="withdrawMessage(message.id)"
|
||||||
|
>
|
||||||
|
撤回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="message-body">
|
<div v-else class="message-body">
|
||||||
<div
|
<div
|
||||||
@ -330,6 +414,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<AssistantResponseCard
|
<AssistantResponseCard
|
||||||
v-if="message.responseCard"
|
v-if="message.responseCard"
|
||||||
:card="message.responseCard"
|
:card="message.responseCard"
|
||||||
|
@generate-report="openSolutionReport"
|
||||||
/>
|
/>
|
||||||
<time class="message-time">{{
|
<time class="message-time">{{
|
||||||
formatMessageTime(message.createdAt)
|
formatMessageTime(message.createdAt)
|
||||||
@ -342,6 +427,13 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<div class="message-avatar">蝶</div>
|
<div class="message-avatar">蝶</div>
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<p class="state-message">{{ loadingText }}</p>
|
<p class="state-message">{{ loadingText }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stop-generation"
|
||||||
|
@click="stopGeneration"
|
||||||
|
>
|
||||||
|
停止生成
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="state-message state-message--error">
|
<p v-if="error" class="state-message state-message--error">
|
||||||
@ -517,6 +609,45 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
text-align: right;
|
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 {
|
.assistant-bubble {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 82%;
|
max-width: 82%;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
createConversation,
|
createConversation,
|
||||||
fetchConversation,
|
fetchConversation,
|
||||||
fetchConversations,
|
fetchConversations,
|
||||||
|
withdrawConversationMessage,
|
||||||
streamConversationMessage,
|
streamConversationMessage,
|
||||||
sendConversationMessage,
|
sendConversationMessage,
|
||||||
} from "@/api/conversations";
|
} from "@/api/conversations";
|
||||||
@ -19,6 +20,9 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const loadingOlder = ref(false);
|
const loadingOlder = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
const generating = ref(false);
|
||||||
|
let activeController: AbortController | null = null;
|
||||||
|
let activeUserMessageId: number | null = null;
|
||||||
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;
|
||||||
@ -144,6 +148,8 @@ 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("当前对话不存在");
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
|
generating.value = true;
|
||||||
|
activeController = new AbortController();
|
||||||
error.value = null;
|
error.value = null;
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
const userMessage = {
|
const userMessage = {
|
||||||
@ -156,6 +162,7 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
semantic: null,
|
semantic: null,
|
||||||
createdAt: startedAt,
|
createdAt: startedAt,
|
||||||
};
|
};
|
||||||
|
activeUserMessageId = userMessage.id;
|
||||||
const assistantMessage = {
|
const assistantMessage = {
|
||||||
id: userMessage.id - 1,
|
id: userMessage.id - 1,
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
@ -178,6 +185,23 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
conversationId,
|
conversationId,
|
||||||
content,
|
content,
|
||||||
(event) => {
|
(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 === "delta" && event.content) onDelta(event.content);
|
||||||
if (event.type === "semantic" && event.data && current.value) {
|
if (event.type === "semantic" && event.data && current.value) {
|
||||||
const messages = [...current.value.messages];
|
const messages = [...current.value.messages];
|
||||||
@ -207,6 +231,7 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
current.value = event.data as Conversation;
|
current.value = event.data as Conversation;
|
||||||
},
|
},
|
||||||
messageType,
|
messageType,
|
||||||
|
activeController.signal,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!current.value ||
|
!current.value ||
|
||||||
@ -223,15 +248,49 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
return current.value;
|
return current.value;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
const conversationId = current.value?.id;
|
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) {
|
if (current.value) {
|
||||||
const messages = current.value.messages.filter(
|
const messages = current.value.messages.filter(
|
||||||
(message) => message.id !== assistantMessage.id,
|
(message) =>
|
||||||
|
message.id !== assistantMessage.id && message.id !== userMessage.id,
|
||||||
);
|
);
|
||||||
current.value = {
|
current.value = {
|
||||||
...current.value,
|
...current.value,
|
||||||
messages,
|
messages,
|
||||||
messageCount: Math.max(0, current.value.messageCount - 1),
|
messageCount: Math.max(0, current.value.messageCount - 2),
|
||||||
messagesTotal: Math.max(0, current.value.messagesTotal - 1),
|
messagesTotal: Math.max(0, current.value.messagesTotal - 2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
@ -246,6 +305,43 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
throw reason;
|
throw reason;
|
||||||
} finally {
|
} finally {
|
||||||
sending.value = false;
|
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,
|
current,
|
||||||
loading,
|
loading,
|
||||||
sending,
|
sending,
|
||||||
|
generating,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
error,
|
error,
|
||||||
loadList,
|
loadList,
|
||||||
@ -277,6 +374,9 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
create,
|
create,
|
||||||
send,
|
send,
|
||||||
streamSend,
|
streamSend,
|
||||||
|
stopGeneration,
|
||||||
|
isGeneratingMessage,
|
||||||
|
withdraw,
|
||||||
reset,
|
reset,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export interface RiskAssessmentResult {
|
|||||||
similarity?: number | null;
|
similarity?: number | null;
|
||||||
}>;
|
}>;
|
||||||
aiSuggestions?: string[];
|
aiSuggestions?: string[];
|
||||||
|
aiSolutionCards?: AiSolutionReport[];
|
||||||
riskBadge?: string | null;
|
riskBadge?: string | null;
|
||||||
alertText?: string | null;
|
alertText?: string | null;
|
||||||
categoryWarning?: {
|
categoryWarning?: {
|
||||||
@ -84,6 +85,24 @@ export interface RiskAssessmentResult {
|
|||||||
dimensionScores?: RiskDimensionScore[];
|
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<RiskAssessmentResult["similarCases"]>;
|
||||||
|
risks?: Array<{ level: string; title: string; detail: string }>;
|
||||||
|
suggestions?: string[];
|
||||||
|
warning?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type RiskReportResult = RiskAssessmentResult;
|
export type RiskReportResult = RiskAssessmentResult;
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user