dev #1

Merged
kx_guo merged 31 commits from dev into main 2026-09-21 10:50:45 +08:00
2 changed files with 164 additions and 107 deletions
Showing only changes of commit 73f574add4 - Show all commits

View File

@ -390,7 +390,7 @@ async function submit(
const value = prompt.value.trim();
if (!value || loading.value) return;
prompt.value = "";
await conversationStore.streamSend(
const sentConversation = await conversationStore.streamSend(
value,
(delta) => {
void delta;
@ -398,15 +398,16 @@ async function submit(
messageType,
);
if (
conversation.value &&
route.query.conversation !== String(conversation.value.id)
sentConversation &&
conversation.value?.id === sentConversation.id &&
route.query.conversation !== String(sentConversation.id)
) {
await router.replace({
name: "assessment",
query: { conversation: String(conversation.value.id) },
query: { conversation: String(sentConversation.id) },
});
}
await scrollToBottom();
if (conversation.value?.id === sentConversation?.id) await scrollToBottom();
}
async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {

View File

@ -1,5 +1,5 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { computed, reactive, ref } from "vue";
import {
createConversation,
@ -17,12 +17,19 @@ export const useConversationStore = defineStore("conversation", () => {
const items = ref<ConversationSummary[]>([]);
const current = ref<Conversation | null>(null);
const loading = ref(false);
const sending = ref(false);
const plainSending = 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;
type StreamState = {
controller: AbortController;
userMessageId: number;
assistantMessageId: number;
serverUserMessageId: number | null;
};
const streamStates = reactive(new Map<number, StreamState>());
const conversationCache = reactive(new Map<number, Conversation>());
const sending = computed(() => plainSending.value || Boolean(current.value && streamStates.has(current.value.id)));
const generating = sending;
let listRequest: Promise<void> | null = null;
let listRequestSearch: string | undefined;
let listLoadedAt = 0;
@ -62,7 +69,19 @@ export const useConversationStore = defineStore("conversation", () => {
try {
const response = await fetchConversation(id);
if (!response.data.data) throw new Error("对话响应为空");
current.value = response.data.data;
const remote = response.data.data;
const cached = conversationCache.get(id);
const stream = streamStates.get(id);
const merged = stream && cached
? {
...remote,
messages: mergePendingMessages(remote.messages, cached.messages, stream),
messageCount: Math.max(remote.messageCount, cached.messageCount),
messagesTotal: Math.max(remote.messagesTotal, cached.messagesTotal),
}
: remote;
conversationCache.set(id, merged);
current.value = merged;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "对话加载失败";
throw reason;
@ -80,20 +99,24 @@ export const useConversationStore = defineStore("conversation", () => {
) {
return false;
}
const offset = current.value.messagesOffset + current.value.messages.length;
const conversationId = current.value.id;
const base = conversationCache.get(conversationId) ?? current.value;
const offset = base.messagesOffset + base.messages.length;
if (!current.value.hasMoreMessages) return false;
loadingOlder.value = true;
try {
const response = await fetchConversation(current.value.id, offset);
const response = await fetchConversation(conversationId, offset);
if (!response.data.data) throw new Error("历史消息响应为空");
const older = response.data.data;
current.value = {
...current.value,
messages: [...older.messages, ...current.value.messages],
const updated = {
...base,
messages: [...older.messages, ...base.messages],
messagesOffset: 0,
hasMoreMessages: older.hasMoreMessages,
messagesTotal: older.messagesTotal,
};
conversationCache.set(conversationId, updated);
if (current.value?.id === conversationId) current.value = updated;
return true;
} finally {
loadingOlder.value = false;
@ -103,6 +126,7 @@ export const useConversationStore = defineStore("conversation", () => {
async function create(title?: string) {
const response = await createConversation(title);
if (!response.data.data) throw new Error("新建对话响应为空");
conversationCache.set(response.data.data.id, response.data.data);
current.value = response.data.data;
items.value = [toSummary(response.data.data), ...items.value];
return current.value;
@ -114,29 +138,30 @@ export const useConversationStore = defineStore("conversation", () => {
) {
if (!current.value) await create();
if (!current.value) throw new Error("当前对话不存在");
sending.value = true;
const conversationId = current.value.id;
plainSending.value = true;
error.value = null;
try {
const response = await sendConversationMessage(
current.value.id,
conversationId,
content,
messageType,
);
if (!response.data.data) throw new Error("消息响应为空");
current.value = response.data.data;
setConversation(response.data.data);
const index = items.value.findIndex(
(item) => item.id === current.value?.id,
(item) => item.id === conversationId,
);
const summary = toSummary(current.value);
const summary = toSummary(response.data.data);
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;
return response.data.data;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "消息发送失败";
throw reason;
} finally {
sending.value = false;
plainSending.value = false;
}
}
@ -147,13 +172,19 @@ 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();
const conversationId = current.value.id;
if (streamStates.has(conversationId)) throw new Error("当前会话正在生成,请稍候");
const state: StreamState = {
controller: new AbortController(),
userMessageId: -Date.now(),
assistantMessageId: -Date.now() - 1,
serverUserMessageId: null,
};
streamStates.set(conversationId, state);
error.value = null;
const startedAt = new Date().toISOString();
const userMessage = {
id: -Date.now(),
id: state.userMessageId,
role: "user" as const,
messageType,
content,
@ -162,9 +193,8 @@ export const useConversationStore = defineStore("conversation", () => {
semantic: null,
createdAt: startedAt,
};
activeUserMessageId = userMessage.id;
const assistantMessage = {
id: userMessage.id - 1,
id: state.assistantMessageId,
role: "assistant" as const,
messageType: "text" as const,
content: "",
@ -173,14 +203,15 @@ export const useConversationStore = defineStore("conversation", () => {
semantic: null as Record<string, unknown> | null,
createdAt: startedAt,
};
current.value = {
const optimistic = {
...current.value,
messages: [...current.value.messages, userMessage, assistantMessage],
messageCount: current.value.messageCount + 2,
messagesTotal: current.value.messagesTotal + 2,
};
conversationCache.set(conversationId, optimistic);
current.value = optimistic;
try {
const conversationId = current.value.id;
await streamConversationMessage(
conversationId,
content,
@ -191,132 +222,156 @@ export const useConversationStore = defineStore("conversation", () => {
typeof event.data === "object" &&
"messageId" in event.data &&
typeof event.data.messageId === "number" &&
current.value
conversationCache.get(conversationId)
) {
const serverMessageId = event.data.messageId;
const messages = current.value.messages.map((message) =>
message.id === userMessage.id
state.serverUserMessageId = serverMessageId;
const cached = conversationCache.get(conversationId);
if (!cached) return;
const messages = cached.messages.map((message) =>
message.id === state.userMessageId
? { ...message, id: serverMessageId }
: message,
);
activeUserMessageId = serverMessageId;
current.value = { ...current.value, messages };
setConversation({ ...cached, 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,
if (event.type === "semantic" && event.data) {
const cached = conversationCache.get(conversationId);
if (!cached) return;
const messages = cached.messages.map((message) =>
message.id === state.assistantMessageId
? {
...message,
semantic: event.data as Record<string, unknown>,
};
current.value = { ...current.value, messages };
}
}
: message,
);
setConversation({ ...cached, 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 === "delta" && event.content) {
const cached = conversationCache.get(conversationId);
if (!cached) return;
const messages = cached.messages.map((message) =>
message.id === state.assistantMessageId
? { ...message, content: message.content + event.content }
: message,
);
setConversation({ ...cached, messages });
}
if (event.type === "error")
throw new Error(event.message || "AI 回复失败");
if (event.type === "done" && event.data)
current.value = event.data as Conversation;
setConversation(event.data as Conversation);
},
messageType,
activeController.signal,
state.controller.signal,
);
const completed = conversationCache.get(conversationId);
if (
!current.value ||
!current.value.messages.some(
!completed ||
!completed.messages.some(
(message) => message.role === "assistant" && message.content.trim(),
)
) {
throw new Error("AI 回复为空");
}
const summary = toSummary(current.value);
const summary = toSummary(completed);
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;
return completed;
} 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 (state.controller.signal.aborted) {
try {
const response = await fetchConversation(conversationId);
if (response.data.data) setConversation(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(
const cached = conversationCache.get(conversationId);
if (cached) {
const messages = cached.messages.filter(
(message) =>
message.id !== assistantMessage.id &&
message.id !== userMessage.id,
);
current.value = {
...current.value,
setConversation({
...cached,
messages,
messageCount: Math.max(0, current.value.messageCount - 2),
messagesTotal: Math.max(0, current.value.messagesTotal - 2),
};
messageCount: Math.max(0, cached.messageCount - 2),
messagesTotal: Math.max(0, cached.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;
const completed = conversationCache.get(conversationId);
if (completed) upsertSummary(completed);
return completed ?? null;
}
if (current.value) {
const messages = current.value.messages.filter(
const cached = conversationCache.get(conversationId);
if (cached) {
const messages = cached.messages.filter(
(message) =>
message.id !== assistantMessage.id && message.id !== userMessage.id,
);
current.value = {
...current.value,
setConversation({
...cached,
messages,
messageCount: Math.max(0, current.value.messageCount - 2),
messagesTotal: Math.max(0, current.value.messagesTotal - 2),
};
messageCount: Math.max(0, cached.messageCount - 2),
messagesTotal: Math.max(0, cached.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.
}
try {
const response = await fetchConversation(conversationId);
if (response.data.data) setConversation(response.data.data);
} catch {
// Keep the cached state when recovery is unavailable.
}
error.value = reason instanceof Error ? reason.message : "AI 回复失败";
if (current.value?.id === conversationId)
error.value = reason instanceof Error ? reason.message : "AI 回复失败";
throw reason;
} finally {
sending.value = false;
generating.value = false;
activeController = null;
activeUserMessageId = null;
streamStates.delete(conversationId);
}
}
function stopGeneration() {
activeController?.abort();
function stopGeneration(conversationId = current.value?.id) {
if (conversationId) streamStates.get(conversationId)?.controller.abort();
}
function isGeneratingMessage(messageId: number) {
return activeUserMessageId === messageId && generating.value;
const state = current.value ? streamStates.get(current.value.id) : undefined;
return Boolean(state && (state.userMessageId === messageId || state.serverUserMessageId === messageId));
}
function setConversation(conversation: Conversation) {
conversationCache.set(conversation.id, conversation);
if (current.value?.id === conversation.id) current.value = conversation;
upsertSummary(conversation);
}
function mergePendingMessages(
remoteMessages: Conversation["messages"],
cachedMessages: Conversation["messages"],
state: StreamState,
) {
const known = new Set(remoteMessages.map((message) => message.id));
const pending = cachedMessages.filter(
(message) =>
(message.id === state.assistantMessageId ||
message.id === state.userMessageId ||
message.id === state.serverUserMessageId) &&
!known.has(message.id),
);
return [...remoteMessages, ...pending];
}
function upsertSummary(conversation: Conversation) {
const summary = toSummary(conversation);
const index = items.value.findIndex((item) => item.id === summary.id);
if (index >= 0) items.value.splice(index, 1, summary);
else items.value.unshift(summary);
items.value.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async function withdraw(messageId: number) {
@ -330,6 +385,7 @@ export const useConversationStore = defineStore("conversation", () => {
);
if (!response.data.data) throw new Error("撤回响应为空");
current.value = response.data.data;
conversationCache.set(current.value.id, current.value);
const index = items.value.findIndex(
(item) => item.id === current.value?.id,
);