实现 AI 案例推理方案生成报告并追加到当前会话报告;查看标准详情跳转标准库;优化标准兜底和相似案例展示;新增停止生成、撤回问题及对应回答功能,并将未接入按钮置灰隐藏。
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import { defineStore } from "pinia";
|
|
import { ref } from "vue";
|
|
|
|
import {
|
|
createConversation,
|
|
fetchConversation,
|
|
fetchConversations,
|
|
withdrawConversationMessage,
|
|
streamConversationMessage,
|
|
sendConversationMessage,
|
|
} from "@/api/conversations";
|
|
import type { Conversation, ConversationSummary } from "@/types/conversation";
|
|
|
|
type ConversationMessageType = "text" | "image" | "file" | "audio";
|
|
|
|
export const useConversationStore = defineStore("conversation", () => {
|
|
const items = ref<ConversationSummary[]>([]);
|
|
const current = ref<Conversation | null>(null);
|
|
const loading = ref(false);
|
|
const sending = ref(false);
|
|
const loadingOlder = ref(false);
|
|
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 listRequestSearch: string | undefined;
|
|
let listLoadedAt = 0;
|
|
|
|
async function loadList(search?: string) {
|
|
const normalizedSearch = search?.trim() || undefined;
|
|
if (listRequest && listRequestSearch === normalizedSearch)
|
|
return listRequest;
|
|
if (
|
|
!normalizedSearch &&
|
|
items.value.length &&
|
|
Date.now() - listLoadedAt < 30_000
|
|
)
|
|
return;
|
|
loading.value = true;
|
|
error.value = null;
|
|
listRequestSearch = normalizedSearch;
|
|
listRequest = (async () => {
|
|
try {
|
|
const response = await fetchConversations(50, normalizedSearch);
|
|
items.value = response.data.data ?? [];
|
|
if (!normalizedSearch) listLoadedAt = Date.now();
|
|
} catch {
|
|
error.value = "历史对话加载失败";
|
|
} finally {
|
|
loading.value = false;
|
|
listRequest = null;
|
|
listRequestSearch = undefined;
|
|
}
|
|
})();
|
|
return listRequest;
|
|
}
|
|
|
|
async function open(id: number) {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const response = await fetchConversation(id);
|
|
if (!response.data.data) throw new Error("对话响应为空");
|
|
current.value = response.data.data;
|
|
} catch (reason) {
|
|
error.value = reason instanceof Error ? reason.message : "对话加载失败";
|
|
throw reason;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadOlder() {
|
|
if (
|
|
loadingOlder.value ||
|
|
!current.value ||
|
|
!current.value.messages.length ||
|
|
!current.value.messagesTotal
|
|
) {
|
|
return false;
|
|
}
|
|
const offset = current.value.messagesOffset + current.value.messages.length;
|
|
if (!current.value.hasMoreMessages) return false;
|
|
loadingOlder.value = true;
|
|
try {
|
|
const response = await fetchConversation(current.value.id, offset);
|
|
if (!response.data.data) throw new Error("历史消息响应为空");
|
|
const older = response.data.data;
|
|
current.value = {
|
|
...current.value,
|
|
messages: [...older.messages, ...current.value.messages],
|
|
messagesOffset: 0,
|
|
hasMoreMessages: older.hasMoreMessages,
|
|
messagesTotal: older.messagesTotal,
|
|
};
|
|
return true;
|
|
} finally {
|
|
loadingOlder.value = false;
|
|
}
|
|
}
|
|
|
|
async function create(title?: string) {
|
|
const response = await createConversation(title);
|
|
if (!response.data.data) throw new Error("新建对话响应为空");
|
|
current.value = response.data.data;
|
|
items.value = [toSummary(response.data.data), ...items.value];
|
|
return current.value;
|
|
}
|
|
|
|
async function send(
|
|
content: string,
|
|
messageType: ConversationMessageType = "text",
|
|
) {
|
|
if (!current.value) await create();
|
|
if (!current.value) throw new Error("当前对话不存在");
|
|
sending.value = true;
|
|
error.value = null;
|
|
try {
|
|
const response = await sendConversationMessage(
|
|
current.value.id,
|
|
content,
|
|
messageType,
|
|
);
|
|
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);
|
|
items.value.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
return current.value;
|
|
} catch (reason) {
|
|
error.value = reason instanceof Error ? reason.message : "消息发送失败";
|
|
throw reason;
|
|
} finally {
|
|
sending.value = false;
|
|
}
|
|
}
|
|
|
|
async function streamSend(
|
|
content: string,
|
|
onDelta: (delta: string) => void,
|
|
messageType: ConversationMessageType = "text",
|
|
) {
|
|
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 = {
|
|
id: -Date.now(),
|
|
role: "user" as const,
|
|
messageType,
|
|
content,
|
|
riskResult: null,
|
|
responseCard: null,
|
|
semantic: null,
|
|
createdAt: startedAt,
|
|
};
|
|
activeUserMessageId = userMessage.id;
|
|
const assistantMessage = {
|
|
id: userMessage.id - 1,
|
|
role: "assistant" as const,
|
|
messageType: "text" as const,
|
|
content: "",
|
|
riskResult: null,
|
|
responseCard: null,
|
|
semantic: null as Record<string, unknown> | null,
|
|
createdAt: startedAt,
|
|
};
|
|
current.value = {
|
|
...current.value,
|
|
messages: [...current.value.messages, userMessage, assistantMessage],
|
|
messageCount: current.value.messageCount + 2,
|
|
messagesTotal: current.value.messagesTotal + 2,
|
|
};
|
|
try {
|
|
const conversationId = current.value.id;
|
|
await streamConversationMessage(
|
|
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];
|
|
const last = messages[messages.length - 1];
|
|
if (last?.role === "assistant") {
|
|
messages[messages.length - 1] = {
|
|
...last,
|
|
semantic: event.data as Record<string, unknown>,
|
|
};
|
|
current.value = { ...current.value, messages };
|
|
}
|
|
}
|
|
if (event.type === "delta" && event.content && current.value) {
|
|
const messages = [...current.value.messages];
|
|
const last = messages[messages.length - 1];
|
|
if (last?.role === "assistant") {
|
|
messages[messages.length - 1] = {
|
|
...last,
|
|
content: last.content + event.content,
|
|
};
|
|
current.value = { ...current.value, messages };
|
|
}
|
|
}
|
|
if (event.type === "error")
|
|
throw new Error(event.message || "AI 回复失败");
|
|
if (event.type === "done" && event.data)
|
|
current.value = event.data as Conversation;
|
|
},
|
|
messageType,
|
|
activeController.signal,
|
|
);
|
|
if (
|
|
!current.value ||
|
|
!current.value.messages.some(
|
|
(message) => message.role === "assistant" && message.content.trim(),
|
|
)
|
|
) {
|
|
throw new Error("AI 回复为空");
|
|
}
|
|
const summary = toSummary(current.value);
|
|
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;
|
|
} 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.id !== userMessage.id,
|
|
);
|
|
current.value = {
|
|
...current.value,
|
|
messages,
|
|
messageCount: Math.max(0, current.value.messageCount - 2),
|
|
messagesTotal: Math.max(0, current.value.messagesTotal - 2),
|
|
};
|
|
}
|
|
if (conversationId) {
|
|
try {
|
|
const response = await fetchConversation(conversationId);
|
|
if (response.data.data) current.value = response.data.data;
|
|
} catch {
|
|
// Keep the local state when recovery is unavailable.
|
|
}
|
|
}
|
|
error.value = reason instanceof Error ? reason.message : "AI 回复失败";
|
|
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;
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
current.value = null;
|
|
error.value = null;
|
|
}
|
|
|
|
function toSummary(conversation: Conversation): ConversationSummary {
|
|
return {
|
|
id: conversation.id,
|
|
title: conversation.title,
|
|
messageCount: conversation.messageCount,
|
|
lastRiskLevel: conversation.lastRiskLevel,
|
|
updatedAt: conversation.updatedAt,
|
|
};
|
|
}
|
|
|
|
return {
|
|
items,
|
|
current,
|
|
loading,
|
|
sending,
|
|
generating,
|
|
loadingOlder,
|
|
error,
|
|
loadList,
|
|
open,
|
|
loadOlder,
|
|
create,
|
|
send,
|
|
streamSend,
|
|
stopGeneration,
|
|
isGeneratingMessage,
|
|
withdraw,
|
|
reset,
|
|
};
|
|
});
|