Files
QuestionProject/ai_knowledge_base_v2/apps/user-client/src/App.vue

345 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import AppDialog from "./components/AppDialog.vue";
import ChatComposer from "./components/ChatComposer.vue";
import ChatHeader from "./components/ChatHeader.vue";
import LoginPanel from "./components/LoginPanel.vue";
import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
import SessionDrawer from "./components/SessionDrawer.vue";
import SessionQuota from "./components/SessionQuota.vue";
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
import type { ChatMessage as ApiMessage, ChatSession, UserProfile } from "./types/api";
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
const activeSessionId = ref<number | null>(null);
const messages = ref<DisplayMessage[]>([]);
const drawerOpen = ref(false);
const sending = ref(false);
const booting = ref(true);
const loadingSession = ref(false);
const loadingSessions = ref(false);
const sessionOperationPending = ref(false);
const logoutDialogOpen = ref(false);
const statusText = ref("连接后端中");
const toastText = ref("");
const followingOutput = ref(true);
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const activeAbortController = ref<AbortController | null>(null);
let toastTimer: number | null = null;
onMounted(async () => {
if (!getToken()) {
booting.value = false;
statusText.value = "请先登录";
return;
}
try {
user.value = await api.profile();
await loadSessions();
statusText.value = "已连接大本营答疑服务";
} catch (error) {
handleError(error, "页面初始化失败");
} finally {
booting.value = false;
}
});
onBeforeUnmount(() => {
activeAbortController.value?.abort();
if (toastTimer) window.clearTimeout(toastTimer);
document.body.classList.remove("drawer-open");
});
async function onLoggedIn(profile: UserProfile) {
user.value = profile;
statusText.value = "已连接大本营答疑服务";
try {
await loadSessions();
} catch (error) {
handleError(error, "会话加载失败");
}
}
async function loadSessions() {
loadingSessions.value = true;
try {
sessions.value = await api.listSessions();
if (sessions.value.length === 0) {
await createSession();
return;
}
await selectSession(sessions.value[0].id, true);
} finally {
loadingSessions.value = false;
}
}
async function createSession() {
if (sessionOperationPending.value) return;
const existingBlank = sessions.value.find((session) => session.messageCount === 0);
if (existingBlank) {
await selectSession(existingBlank.id);
return;
}
sessionOperationPending.value = true;
try {
const result = await api.createSession();
sessions.value = await api.listSessions();
await selectSession(result.sessionId, true);
} catch (error) {
handleError(error, "新建会话失败");
} finally {
sessionOperationPending.value = false;
}
}
async function selectSession(sessionId: number, force = false) {
if (!force && activeSessionId.value === sessionId && !loadingSession.value) {
drawerOpen.value = false;
return;
}
loadingSession.value = true;
try {
const history = await api.history(sessionId);
activeSessionId.value = sessionId;
messages.value = history.map(toUiMessage);
followingOutput.value = true;
drawerOpen.value = false;
await messageList.value?.scrollToBottom("auto");
await refreshProfile();
} catch (error) {
handleError(error, "会话加载失败");
} finally {
loadingSession.value = false;
}
}
async function send(message: string, complete: (success: boolean) => void) {
if (!activeSessionId.value || sending.value) {
complete(false);
return;
}
const now = new Date().toISOString();
const userMessage: DisplayMessage = { id: `user-${Date.now()}`, role: "user", content: message, createdAt: now };
const assistantMessage: DisplayMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: "",
createdAt: now,
streaming: true,
};
messages.value.push(userMessage, assistantMessage);
const assistantIndex = messages.value.length - 1;
const currentAssistant = () => messages.value[assistantIndex];
sending.value = true;
followingOutput.value = true;
activeAbortController.value = new AbortController();
let hasContent = false;
await messageList.value?.scrollToMessage(userMessage.id);
try {
await streamChat(
activeSessionId.value,
message,
async (chunk) => {
if (!hasContent) {
currentAssistant().content = "";
hasContent = true;
}
currentAssistant().content += chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
async (chunk) => {
currentAssistant().reasoning = (currentAssistant().reasoning || "") + chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
async (statusMessage, statusType, reasoningVisible) => {
if (statusType === "queued") currentAssistant().content = statusMessage || "当前请求较多,正在排队中。";
if (statusType === "generating") {
currentAssistant().showReasoning = reasoningVisible;
if (!hasContent) currentAssistant().content = "";
}
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
activeAbortController.value.signal,
);
currentAssistant().streaming = false;
currentAssistant().createdAt = new Date().toISOString();
complete(true);
await refreshSessionList();
await refreshProfile();
} catch (error) {
currentAssistant().streaming = false;
if (error instanceof DOMException && error.name === "AbortError") {
currentAssistant().content = currentAssistant().content || "已停止生成";
complete(true);
} else {
currentAssistant().content = currentAssistant().content || "回答生成失败,请稍后重试。";
complete(false);
handleError(error, "AI 回复失败");
}
} finally {
sending.value = false;
activeAbortController.value = null;
}
}
async function stop() {
if (!activeSessionId.value || !sending.value) return;
activeAbortController.value?.abort();
try {
await api.stop(activeSessionId.value);
} catch (error) {
handleError(error, "停止生成失败");
}
}
async function renameSession(sessionId: number, title: string, done: (success: boolean) => void) {
if (sessionOperationPending.value) return;
sessionOperationPending.value = true;
try {
await api.renameSession(sessionId, title);
await refreshSessionList();
showToast("会话名称已更新");
done(true);
} catch (error) {
done(false);
handleError(error, "会话改名失败");
} finally {
sessionOperationPending.value = false;
}
}
async function deleteSession(sessionId: number, done: (success: boolean) => void) {
if (sessionOperationPending.value) return;
sessionOperationPending.value = true;
try {
await api.deleteSession(sessionId);
sessions.value = await api.listSessions();
done(true);
showToast("会话已删除");
if (activeSessionId.value === sessionId) {
activeSessionId.value = null;
messages.value = [];
const nextSession = sessions.value[0];
if (nextSession) await selectSession(nextSession.id, true);
else {
sessionOperationPending.value = false;
await createSession();
}
}
} catch (error) {
done(false);
handleError(error, "会话删除失败");
} finally {
sessionOperationPending.value = false;
}
}
async function confirmLogout() {
sessionOperationPending.value = true;
try {
await api.logout();
} catch {
// 本地登录态必须可退出,服务端失败不阻塞用户操作。
} finally {
sessionOperationPending.value = false;
logoutDialogOpen.value = false;
clearUserState();
}
}
async function refreshSessionList() {
sessions.value = await api.listSessions();
}
async function refreshProfile() {
user.value = await api.profile();
}
function toUiMessage(message: ApiMessage): DisplayMessage {
return {
id: String(message.id),
role: message.role,
content: message.content,
showReasoning: message.role === "assistant" && /<think(?:\s[^>]*)?>/i.test(message.content),
createdAt: message.created_at,
streaming: message.message_status === "GENERATING",
};
}
function handleError(error: unknown, fallback: string) {
if (error instanceof ApiError && error.status === 401) {
clearUserState();
showToast("登录状态已失效,请重新登录");
return;
}
showToast(error instanceof Error ? error.message : fallback);
}
function clearUserState() {
clearToken();
user.value = null;
sessions.value = [];
messages.value = [];
activeSessionId.value = null;
statusText.value = "请先登录";
}
function showToast(message: string) {
toastText.value = message;
if (toastTimer) window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
toastText.value = "";
toastTimer = null;
}, 3200);
}
</script>
<template>
<main class="app-shell">
<section class="phone-frame" :class="{ 'login-mode': !user && !booting, 'chat-mode': Boolean(user) }">
<div v-if="booting" class="booting">正在启动...</div>
<LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
<template v-else>
<ChatHeader :user="user" :status-text="statusText" @open-history="drawerOpen = true" @logout="logoutDialogOpen = true" />
<SessionQuota :used="user.todayUsed" :limit="user.dailyLimit" :entitlement="user.entitlement" />
<MessageList
ref="messageList"
:messages="messages"
:loading-session="loadingSession"
@follow-change="followingOutput = $event"
/>
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
<SessionDrawer
:open="drawerOpen"
:sessions="sessions"
:active-session-id="activeSessionId"
:loading="loadingSessions"
:operation-pending="sessionOperationPending"
@close="drawerOpen = false"
@select="selectSession"
@create="createSession"
@rename="renameSession"
@delete="deleteSession"
/>
</template>
</section>
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
<AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
<p class="confirm-copy">确定退出当前账号吗</p>
<template #footer>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" :disabled="sessionOperationPending" @click="logoutDialogOpen = false">取消</button>
<button type="button" class="dialog-primary" :disabled="sessionOperationPending" @click="confirmLogout">
{{ sessionOperationPending ? "退出中..." : "退出" }}
</button>
</div>
</template>
</AppDialog>
</main>
</template>