Compare commits

..

3 Commits

Author SHA1 Message Date
92a86efe73 优化报告信息与对话思考状态展示 2026-09-15 10:43:02 +08:00
b5484c8adb 优化快捷入口与风险项管理展示 2026-09-14 15:51:36 +08:00
6402aaf83e 支持删除历史会话 2026-09-13 17:36:22 +08:00
11 changed files with 284 additions and 89 deletions

View File

@ -29,6 +29,10 @@ export function fetchConversation(id: number, offset = 0) {
}); });
} }
export function deleteConversation(id: number) {
return http.delete<ApiResponse<null>>(`/v1/conversations/${id}`);
}
export function sendConversationMessage( export function sendConversationMessage(
id: number, id: number,
content: string, content: string,

View File

@ -11,6 +11,8 @@ import {
Search, Search,
} from "@lucide/vue"; } from "@lucide/vue";
import { ref } from "vue";
import { featureFlags } from "@/config/features"; import { featureFlags } from "@/config/features";
defineProps<{ modelValue: string; loading: boolean; canUpload: boolean }>(); defineProps<{ modelValue: string; loading: boolean; canUpload: boolean }>();
@ -31,6 +33,13 @@ const emit = defineEmits<{
| "voice", | "voice",
]; ];
}>(); }>();
const textarea = ref<HTMLTextAreaElement | null>(null);
function focus() {
textarea.value?.focus();
}
defineExpose({ focus });
const functions: Array<{ const functions: Array<{
title: string; title: string;
@ -115,6 +124,7 @@ const functions: Array<{
<Camera :size="16" /> <Camera :size="16" />
</button> </button>
<textarea <textarea
ref="textarea"
:value="modelValue" :value="modelValue"
rows="1" rows="1"
placeholder="输入产品参数或问题…" placeholder="输入产品参数或问题…"
@ -145,7 +155,11 @@ const functions: Array<{
:key="item.title" :key="item.title"
class="function-card" class="function-card"
type="button" type="button"
@click="emit('tool', item.tool)" @click="
item.tool === 'assessment' || !item.prompt
? emit('tool', item.tool)
: emit('select', item.prompt)
"
> >
<span class="function-dot" :class="`function-dot--${item.tone}`"></span> <span class="function-dot" :class="`function-dot--${item.tone}`"></span>
<component :is="item.icon" :size="13" /> <component :is="item.icon" :size="13" />

View File

@ -258,7 +258,6 @@ function generateReport() {
<b>{{ standard.riskPoint }}</b <b>{{ standard.riskPoint }}</b
><small>{{ standard.rule }} · {{ standard.source }}</small> ><small>{{ standard.rule }} · {{ standard.source }}</small>
</div> </div>
<strong>{{ standard.status }}</strong>
</div> </div>
</template> </template>
<p v-else class="empty">暂无直接匹配规则</p> <p v-else class="empty">暂无直接匹配规则</p>
@ -317,12 +316,6 @@ function generateReport() {
{{ standard.source || "标准文件.xlsx" }}</small {{ standard.source || "标准文件.xlsx" }}</small
> >
</div> </div>
<strong
:class="{
ok: standard.status === '合规' || standard.status === '打样必测',
}"
>{{ standard.status }}</strong
>
</button> </button>
<p v-if="!data.standards?.length" class="empty"> <p v-if="!data.standards?.length" class="empty">
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。 当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
@ -696,11 +689,6 @@ function generateReport() {
color: var(--color-text-faint); color: var(--color-text-faint);
font-size: 9px; font-size: 9px;
} }
.standard-row > strong {
flex: 0 0 auto;
color: var(--color-warning);
font-size: 10px;
}
.solution-standard-row { .solution-standard-row {
display: flex; display: flex;
width: 100%; width: 100%;
@ -753,14 +741,6 @@ function generateReport() {
color: var(--color-text-faint); color: var(--color-text-faint);
font-size: 10px; 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;

View File

@ -13,7 +13,10 @@ import { computed } from "vue";
import { featureFlags } from "@/config/features"; import { featureFlags } from "@/config/features";
const emit = defineEmits<{ select: [prompt: string] }>(); const emit = defineEmits<{
select: [prompt: string];
tool: [value: "assessment" | "ocr"];
}>();
const props = defineProps<{ canUpload: boolean }>(); const props = defineProps<{ canUpload: boolean }>();
const actions = [ const actions = [
@ -23,6 +26,7 @@ const actions = [
prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元", prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元",
icon: Search, icon: Search,
tone: "blue", tone: "blue",
tool: "assessment" as const,
}, },
{ {
title: "查供应商", title: "查供应商",
@ -69,6 +73,7 @@ const actions = [
icon: Camera, icon: Camera,
tone: "sky", tone: "sky",
requiresUpload: true, requiresUpload: true,
tool: "ocr" as const,
}, },
...(featureFlags.voiceEntry ...(featureFlags.voiceEntry
? [ ? [
@ -90,6 +95,14 @@ const visibleActions = computed(() =>
props.canUpload, props.canUpload,
), ),
); );
function handleAction(action: (typeof actions)[number]) {
if ("tool" in action && action.tool) {
emit("tool", action.tool);
return;
}
emit("select", action.prompt);
}
</script> </script>
<template> <template>
@ -107,7 +120,7 @@ const visibleActions = computed(() =>
:key="action.title" :key="action.title"
class="welcome-action" class="welcome-action"
type="button" type="button"
@click="emit('select', action.prompt)" @click="handleAction(action)"
> >
<span class="welcome-action__icon" :class="`tone-${action.tone}`"> <span class="welcome-action__icon" :class="`tone-${action.tone}`">
<component :is="action.icon" :size="16" /> <component :is="action.icon" :size="16" />

View File

@ -108,13 +108,6 @@ const standardCategories = computed(() => [
(props.result.standardJudgment ?? []).map((item) => item.category), (props.result.standardJudgment ?? []).map((item) => item.category),
), ),
]); ]);
const pendingStandardCount = computed(
() =>
(props.result.standardJudgment ?? []).filter(
(item) => !["合规", "合格", "达标"].includes(item.status),
).length,
);
function openStandards(category = "all", keyword?: string) { function openStandards(category = "all", keyword?: string) {
void router.push({ void router.push({
name: "standards", name: "standards",
@ -287,18 +280,12 @@ function openSupplier() {
{{ standard.source || "标准文件.xlsx" }}</small {{ standard.source || "标准文件.xlsx" }}</small
> >
</div> </div>
<strong :class="{ 'status-action': standard.status !== '待检测' }">{{
standard.status
}}</strong>
</button> </button>
<footer> <footer>
<span class="standards-card__engine-icon">↻</span> <span class="standards-card__engine-icon">↻</span>
<span <span
><b>双引擎融合:</b ><b>双引擎融合:</b
>历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。<template >历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。</span
v-if="pendingStandardCount"
>{{ pendingStandardCount }} 项待检标准已加入检测任务清单</template
></span
> >
<span class="standards-card__links"> <span class="standards-card__links">
<button <button
@ -895,16 +882,6 @@ h3 {
font-size: 9px; font-size: 9px;
} }
.standard-row > strong {
flex: 0 0 auto;
color: var(--color-warning);
font-size: 10px;
}
.standard-row > .status-action {
color: var(--color-success);
}
.standards-card__empty, .standards-card__empty,
.case-empty { .case-empty {
margin: 8px 0; margin: 8px 0;

View File

@ -14,18 +14,18 @@ const { user } = storeToRefs(auth);
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "管理"]; const navigation = ["对话", "看板", "供应商", "标准库", "报告", "风险项管理"];
const navigationRoutes: Record<string, string> = { const navigationRoutes: Record<string, string> = {
对话: "assessment", 对话: "assessment",
看板: "overview", 看板: "overview",
供应商: "supplier", 供应商: "supplier",
标准库: "standards", 标准库: "standards",
报告: "report", 报告: "report",
管理: "review", 风险项管理: "review",
}; };
const visibleNavigation = computed(() => const visibleNavigation = computed(() =>
navigation.filter( navigation.filter(
(item) => item !== "管理" || auth.hasPermission("risk_item.review"), (item) => item !== "风险项管理" || auth.hasPermission("risk_item.review"),
), ),
); );
@ -34,7 +34,7 @@ const routeChunks: Record<string, () => Promise<unknown>> = {
供应商: () => import("@/pages/SupplierProfilePage.vue"), 供应商: () => import("@/pages/SupplierProfilePage.vue"),
标准库: () => import("@/pages/StandardsPage.vue"), 标准库: () => import("@/pages/StandardsPage.vue"),
报告: () => import("@/pages/ReportPage.vue"), 报告: () => import("@/pages/ReportPage.vue"),
管理: () => import("@/pages/ReviewPage.vue"), 风险项管理: () => import("@/pages/ReviewPage.vue"),
}; };
const prefetched = new Set<string>(); const prefetched = new Set<string>();
function prefetch(item: string) { function prefetch(item: string) {
@ -67,7 +67,7 @@ function prefetch(item: string) {
(item === '供应商' && route.name === 'supplier') || (item === '供应商' && route.name === 'supplier') ||
(item === '标准库' && route.name === 'standards') || (item === '标准库' && route.name === 'standards') ||
(item === '报告' && route.name === 'report') || (item === '报告' && route.name === 'report') ||
(item === '管理' && route.name === 'review') || (item === '风险项管理' && route.name === 'review') ||
(item === '对话' && route.name === 'assessment'), (item === '对话' && route.name === 'assessment'),
}" }"
@mouseenter="prefetch(item)" @mouseenter="prefetch(item)"

View File

@ -95,6 +95,18 @@ async function openConversation(id: number) {
} }
} }
async function removeConversation(item: { id: number; title: string }) {
if (!window.confirm(`确定删除会话“${item.title}”吗?删除后无法恢复。`)) return;
try {
await conversationStore.remove(item.id);
if (conversationStore.current === null) {
await router.push({ name: "assessment", query: { new: String(Date.now()) } });
}
} catch {
// The store exposes the request error.
}
}
function searchConversations() { function searchConversations() {
void conversationStore.loadList(search.value.trim() || undefined); void conversationStore.loadList(search.value.trim() || undefined);
} }
@ -131,14 +143,15 @@ function searchConversations() {
<p v-else-if="!conversations.length" class="sidebar-state"> <p v-else-if="!conversations.length" class="sidebar-state">
暂无历史对话 暂无历史对话
</p> </p>
<button <div
v-for="item in conversations" v-for="item in conversations"
:key="item.id" :key="item.id"
class="sidebar-item" class="sidebar-item"
:class="{ :class="{
'sidebar-item--active': conversationStore.current?.id === item.id, 'sidebar-item--active': conversationStore.current?.id === item.id,
}" }"
type="button" role="button"
tabindex="0"
@click="openConversation(item.id)" @click="openConversation(item.id)"
> >
<span <span
@ -148,7 +161,16 @@ function searchConversations() {
></span> ></span>
<span v-else class="risk-dot risk-dot--empty"></span> <span v-else class="risk-dot risk-dot--empty"></span>
<span class="sidebar-item__label">{{ item.title }}</span> <span class="sidebar-item__label">{{ item.title }}</span>
<button
type="button"
class="sidebar-item__delete"
title="删除会话"
aria-label="删除会话"
@click.stop="removeConversation(item)"
>
×
</button> </button>
</div>
</section> </section>
<section> <section>
@ -264,6 +286,32 @@ section h2 {
white-space: nowrap; white-space: nowrap;
} }
.sidebar-item__delete {
display: none;
width: 22px;
height: 22px;
flex: 0 0 22px;
margin-left: auto;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--color-text-faint);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.sidebar-item:hover .sidebar-item__delete,
.sidebar-item:focus-within .sidebar-item__delete {
display: inline-grid;
place-items: center;
}
.sidebar-item__delete:hover {
background: var(--color-danger-subtle);
color: var(--color-danger);
}
.risk-dot { .risk-dot {
width: 5px; width: 5px;
height: 5px; height: 5px;

View File

@ -220,11 +220,7 @@ function dimensionConclusion(item: {
蝶雅产品部 · 风险预警系统 · 自动生成 · {{ generatedAt }} 蝶雅产品部 · 风险预警系统 · 自动生成 · {{ generatedAt }}
</p> </p>
<div class="report-meta"> <div class="report-meta">
<span><b>产品/评估对象:</b>{{ report.subjectId }}</span> <span
><span
><b>供应商:</b
>{{ report.supplierFreq?.supplierName || "未指定" }}</span
><span
><b>报告编号:</b>RP-{{ report.subjectId.slice(0, 8) }}-{{ ><b>报告编号:</b>RP-{{ report.subjectId.slice(0, 8) }}-{{
report.score.toFixed(0) report.score.toFixed(0)
}}</span }}</span

View File

@ -264,8 +264,8 @@ onMounted(() => {
<div class="admin-page"> <div class="admin-page">
<header class="admin-header"> <header class="admin-header">
<div> <div>
<p class="eyebrow">ADMIN CONSOLE</p> <p class="eyebrow">RISK ITEM CONSOLE</p>
<h1>管理工作台</h1> <h1>风险项管理工作台</h1>
<p class="admin-description">风险项录入、审核与数据质量管理</p> <p class="admin-description">风险项录入、审核与数据质量管理</p>
</div> </div>
<div class="admin-header-actions"> <div class="admin-header-actions">
@ -692,8 +692,8 @@ onMounted(() => {
><ClipboardCheck :size="16" ><ClipboardCheck :size="16"
/></span> /></span>
<div> <div>
<h3>风险项录入</h3> <h3>风险项表单录入</h3>
<p>表单录入并提交审核</p> <p>填写风险项并提交审核</p>
</div> </div>
</header> </header>
<button <button
@ -701,7 +701,7 @@ onMounted(() => {
type="button" type="button"
@click="router.push({ name: 'risk-item-entry' })" @click="router.push({ name: 'risk-item-entry' })"
> >
<Plus :size="14" />打开录入表单 <Plus :size="14" />新建风险项
</button> </button>
</article> </article>
<article <article
@ -713,20 +713,20 @@ onMounted(() => {
><ImagePlus :size="16" ><ImagePlus :size="16"
/></span> /></span>
<div> <div>
<h3>OCR 识别录入</h3> <h3>风险项 OCR 录入</h3>
<p>上传检测报告、巡检单或异常报告</p> <p>识别报告并生成待审核风险项</p>
</div> </div>
</header> </header>
<button class="tool-link" type="button" @click="ocrOpen = true"> <button class="tool-link" type="button" @click="ocrOpen = true">
<Upload :size="14" />开始 OCR 识别 <Upload :size="14" />识别并录入风险项
</button> </button>
</article> </article>
<article v-if="featureFlags.voiceEntry" class="tool-card"> <article v-if="featureFlags.voiceEntry" class="tool-card">
<header> <header>
<span class="tool-icon tool-icon--purple"><Mic :size="16" /></span> <span class="tool-icon tool-icon--purple"><Mic :size="16" /></span>
<div> <div>
<h3>语音录入</h3> <h3>风险项语音录入</h3>
<p>口述异常信息,自动提取字段</p> <p>口述异常并生成待审核风险项</p>
</div> </div>
</header> </header>
<button class="voice-entry" type="button"> <button class="voice-entry" type="button">
@ -742,12 +742,12 @@ onMounted(() => {
><FileSpreadsheet :size="16" ><FileSpreadsheet :size="16"
/></span> /></span>
<div> <div>
<h3>Excel 批量导入</h3> <h3>风险项 Excel 批量导入</h3>
<p>按模板批量进入审核队列</p> <p>按模板批量生成待审核风险项</p>
</div> </div>
</header> </header>
<button class="tool-link" type="button"> <button class="tool-link" type="button">
<Upload :size="14" />上传 Excel 文件</button <Upload :size="14" />批量导入风险项</button
><small class="tool-hint">单次最多 1000 条,自动去重校验</small> ><small class="tool-hint">单次最多 1000 条,自动去重校验</small>
</article> </article>
<article class="tool-card config-card"> <article class="tool-card config-card">

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios"; import axios from "axios";
import { computed, nextTick, onMounted, ref, watch } from "vue"; import { computed, nextTick, onActivated, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import AssistantComposer from "@/components/assistant/AssistantComposer.vue"; import AssistantComposer from "@/components/assistant/AssistantComposer.vue";
@ -31,6 +31,7 @@ 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);
const composer = ref<InstanceType<typeof AssistantComposer> | null>(null);
const tool = ref< const tool = ref<
| "assessment" | "assessment"
| "supplier" | "supplier"
@ -44,6 +45,7 @@ const tool = ref<
const toolInput = ref(""); const toolInput = ref("");
const toolError = ref(""); const toolError = ref("");
const toolFile = ref<File | null>(null); const toolFile = ref<File | null>(null);
const expandedReplies = ref(new Set<number>());
const assessmentFields = ref({ const assessmentFields = ref({
category: "", category: "",
specification: "", specification: "",
@ -109,6 +111,12 @@ onMounted(async () => {
await openFromRoute(); await openFromRoute();
}); });
onActivated(async () => {
// MainLayout keeps this page alive. Returning from report/dashboard therefore
// does not remount it, so explicitly reveal the newest message on re-entry.
if (conversation.value?.messages.length) await scrollToBottom("auto");
});
watch( watch(
() => route.query.conversation, () => route.query.conversation,
async () => { async () => {
@ -116,9 +124,45 @@ watch(
}, },
); );
function usePrompt(value: string) { async function usePrompt(value: string) {
prompt.value = value; const current = prompt.value.trim();
void submit(); prompt.value = current ? `${current}\n${value}` : value;
await nextTick();
composer.value?.focus();
}
function toggleReply(messageId: number) {
const next = new Set(expandedReplies.value);
if (next.has(messageId)) next.delete(messageId);
else next.add(messageId);
expandedReplies.value = next;
}
function canCollapseReply(content: string) {
return content.trim().length > 90;
}
function isMessageThinking(messageId: number, index: number) {
const messages = conversation.value?.messages ?? [];
return (
generating.value &&
index === messages.length - 1 &&
messages[index]?.id === messageId &&
messages[index]?.role === "assistant"
);
}
function thinkingDuration(index: number) {
const messages = conversation.value?.messages ?? [];
const message = messages[index];
const previous = messages[index - 1];
if (!message || !previous || previous.role !== "user") return null;
const elapsed = Math.round(
(new Date(message.createdAt).getTime() -
new Date(previous.createdAt).getTime()) /
1000,
);
return elapsed > 0 ? elapsed : null;
} }
function openTool( function openTool(
@ -210,14 +254,9 @@ function submitTool() {
return; return;
} }
closeTool(); closeTool();
prompt.value = void usePrompt(
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`; finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`,
const messageType = toolFile.value );
? selectedTool === "ocr"
? "image"
: "audio"
: "text";
void submit(messageType);
} }
function selectFile(event: Event) { function selectFile(event: Event) {
@ -277,7 +316,11 @@ async function openFromRoute() {
const rawId = route.query.conversation; const rawId = route.query.conversation;
if (typeof rawId !== "string") return; if (typeof rawId !== "string") return;
const id = Number(rawId); const id = Number(rawId);
if (Number.isSafeInteger(id) && id > 0 && conversation.value?.id !== id) { if (Number.isSafeInteger(id) && id > 0 && conversation.value?.id === id) {
await scrollToBottom("auto");
return;
}
if (Number.isSafeInteger(id) && id > 0) {
try { try {
await conversationStore.open(id); await conversationStore.open(id);
await scrollToBottom("auto"); await scrollToBottom("auto");
@ -434,10 +477,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
v-if="!conversation?.messages.length" v-if="!conversation?.messages.length"
:can-upload="auth.hasPermission('risk_item.submit')" :can-upload="auth.hasPermission('risk_item.submit')"
@select="usePrompt" @select="usePrompt"
@tool="openTool"
/> />
<template <template
v-for="message in conversation?.messages ?? []" v-for="(message, messageIndex) in conversation?.messages ?? []"
:key="message.id" :key="message.id"
> >
<div class="message" :class="`message--${message.role}`"> <div class="message" :class="`message--${message.role}`">
@ -488,13 +532,70 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<div <div
v-if=" v-if="
message.content && message.content &&
!message.riskResult && (message.riskResult ||
!message.responseCard message.responseCard ||
isMessageThinking(message.id, messageIndex))
" "
class="assistant-thinking"
>
<button
type="button"
class="assistant-thinking__header"
:disabled="
isMessageThinking(message.id, messageIndex) ||
!canCollapseReply(message.content)
"
@click="toggleReply(message.id)"
>
<span v-if="isMessageThinking(message.id, messageIndex)"
>思考中…</span
>
<span v-else>
已完成<span v-if="thinkingDuration(messageIndex)">
{{ thinkingDuration(messageIndex) }}s</span
>
</span>
<span
v-if="canCollapseReply(message.content)"
class="assistant-thinking__arrow"
>{{ expandedReplies.has(message.id) ? "⌄" : "›" }}</span
>
</button>
<div
class="assistant-bubble" class="assistant-bubble"
:class="{
'assistant-bubble--collapsed':
canCollapseReply(message.content) &&
!expandedReplies.has(message.id),
}"
> >
{{ message.content }} {{ message.content }}
</div> </div>
</div>
<div
v-else-if="message.content"
class="assistant-bubble"
:class="{
'assistant-bubble--collapsed':
canCollapseReply(message.content) &&
!expandedReplies.has(message.id),
}"
>
{{ message.content }}
</div>
<button
v-if="
message.content &&
!message.riskResult &&
!message.responseCard &&
canCollapseReply(message.content)
"
type="button"
class="reply-toggle"
@click="toggleReply(message.id)"
>
{{ expandedReplies.has(message.id) ? "收起" : "展开全文" }}
</button>
<RiskAnalysisCard <RiskAnalysisCard
v-if="message.riskResult" v-if="message.riskResult"
:result="message.riskResult" :result="message.riskResult"
@ -533,6 +634,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
</div> </div>
<AssistantComposer <AssistantComposer
ref="composer"
v-model="prompt" v-model="prompt"
:loading="loading" :loading="loading"
:can-upload="auth.hasPermission('risk_item.submit')" :can-upload="auth.hasPermission('risk_item.submit')"
@ -740,6 +842,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
} }
.assistant-bubble { .assistant-bubble {
position: relative;
width: fit-content; width: fit-content;
max-width: 82%; max-width: 82%;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -751,6 +854,54 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
line-height: 1.65; line-height: 1.65;
} }
.assistant-bubble--collapsed {
display: -webkit-box;
max-height: calc(2 * 1.65em + 22px);
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.assistant-thinking {
margin-bottom: 8px;
}
.assistant-thinking__header {
display: flex;
width: auto;
align-items: center;
gap: 5px;
border: 0;
margin: 0 0 5px 2px;
padding: 0;
background: transparent;
color: var(--color-text-muted);
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.assistant-thinking__header:disabled {
cursor: default;
}
.assistant-thinking__arrow {
color: var(--color-text-faint);
font-size: 15px;
line-height: 1;
}
.reply-toggle {
display: block;
margin-top: 4px;
border: 0;
padding: 0;
background: transparent;
color: var(--color-accent);
font-size: 11px;
cursor: pointer;
}
.semantic-strip { .semantic-strip {
margin-bottom: 8px; margin-bottom: 8px;
color: var(--color-text-muted); color: var(--color-text-muted);

View File

@ -3,6 +3,7 @@ import { computed, reactive, ref } from "vue";
import { import {
createConversation, createConversation,
deleteConversation,
fetchConversation, fetchConversation,
fetchConversations, fetchConversations,
withdrawConversationMessage, withdrawConversationMessage,
@ -132,6 +133,16 @@ export const useConversationStore = defineStore("conversation", () => {
return current.value; return current.value;
} }
async function remove(id: number) {
const active = current.value?.id === id;
await deleteConversation(id);
streamStates.get(id)?.controller.abort();
streamStates.delete(id);
conversationCache.delete(id);
items.value = items.value.filter((item) => item.id !== id);
if (active) current.value = null;
}
async function send( async function send(
content: string, content: string,
messageType: ConversationMessageType = "text", messageType: ConversationMessageType = "text",
@ -428,6 +439,7 @@ export const useConversationStore = defineStore("conversation", () => {
open, open,
loadOlder, loadOlder,
create, create,
remove,
send, send,
streamSend, streamSend,
stopGeneration, stopGeneration,