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

229 lines
6.9 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 { nextTick, onMounted, ref } from "vue";
import ChatComposer from "./components/ChatComposer.vue";
import ChatMessage from "./components/ChatMessage.vue";
import LoginPanel from "./components/LoginPanel.vue";
import SessionDrawer from "./components/SessionDrawer.vue";
import { api, clearToken, getToken, streamChat } from "./services/api";
import type { ChatMessage as ApiMessage, ChatSession, UserProfile } from "./types/api";
interface UiMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
}
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
const activeSessionId = ref<number | null>(null);
const messages = ref<UiMessage[]>([]);
const drawerOpen = ref(false);
const loading = ref(false);
const booting = ref(true);
const statusText = ref("连接后端中");
const bottomRef = ref<HTMLElement | null>(null);
const activeAbortController = ref<AbortController | null>(null);
onMounted(async () => {
if (!getToken()) {
booting.value = false;
statusText.value = "请先登录";
return;
}
try {
user.value = await api.profile();
await loadSessions();
statusText.value = "已连接大本营答疑服务";
} catch {
clearToken();
statusText.value = "登录已失效";
} finally {
booting.value = false;
}
});
async function onLoggedIn(profile: UserProfile) {
user.value = profile;
statusText.value = "已连接大本营答疑服务";
await loadSessions();
}
async function loadSessions() {
sessions.value = await api.listSessions();
if (sessions.value.length === 0) {
await createSession();
return;
}
await selectSession(sessions.value[0].id);
}
async function createSession() {
const result = await api.createSession();
await refreshSessions(result.sessionId);
drawerOpen.value = false;
}
async function refreshSessions(selectId = activeSessionId.value) {
sessions.value = await api.listSessions();
if (selectId) await selectSession(selectId);
}
async function selectSession(sessionId: number) {
activeSessionId.value = sessionId;
const history = await api.history(sessionId);
messages.value = history.map(toUiMessage);
drawerOpen.value = false;
await scrollToBottom();
}
async function send(message: string) {
if (!activeSessionId.value || loading.value) return;
const userMessage: UiMessage = { id: `user-${Date.now()}`, role: "user", content: message };
const assistantMessage: UiMessage = { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true };
messages.value.push(userMessage, assistantMessage);
loading.value = true;
activeAbortController.value = new AbortController();
let hasContent = false;
await scrollToBottom();
try {
await streamChat(
activeSessionId.value,
message,
async (chunk) => {
if (!hasContent) {
assistantMessage.content = "";
hasContent = true;
}
assistantMessage.content += chunk;
await scrollToBottom();
},
async (statusMessage, statusType) => {
if (statusType === "queued") {
assistantMessage.content = statusMessage || "当前请求较多,正在排队中。";
} else if (statusType === "generating" && !hasContent) {
assistantMessage.content = "";
}
await scrollToBottom();
},
activeAbortController.value.signal,
);
assistantMessage.streaming = false;
await refreshSessions(activeSessionId.value);
user.value = await api.profile();
} catch (error) {
assistantMessage.streaming = false;
if (error instanceof DOMException && error.name === "AbortError") {
assistantMessage.content = assistantMessage.content || "已停止生成";
} else {
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
}
} finally {
loading.value = false;
activeAbortController.value = null;
}
}
async function stop() {
if (!activeSessionId.value) return;
activeAbortController.value?.abort();
await api.stop(activeSessionId.value);
loading.value = false;
}
async function renameSession(sessionId: number, title: string) {
await api.renameSession(sessionId, title);
await refreshSessions(sessionId);
}
async function deleteSession(sessionId: number) {
await api.deleteSession(sessionId);
sessions.value = await api.listSessions();
if (activeSessionId.value === sessionId) {
messages.value = [];
activeSessionId.value = null;
if (sessions.value.length > 0) {
await selectSession(sessions.value[0].id);
}
}
}
async function logout() {
try {
await api.logout();
} finally {
clearToken();
user.value = null;
sessions.value = [];
messages.value = [];
activeSessionId.value = null;
statusText.value = "请先登录";
}
}
function toUiMessage(message: ApiMessage): UiMessage {
return {
id: String(message.id),
role: message.role,
content: message.content,
streaming: message.message_status === "GENERATING",
};
}
async function scrollToBottom() {
await nextTick();
bottomRef.value?.scrollIntoView({ behavior: "smooth", block: "end" });
}
</script>
<template>
<main class="app-shell">
<section class="phone-frame">
<div v-if="booting" class="booting">正在启动...</div>
<LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
<template v-else>
<header class="topbar">
<button type="button" class="icon-btn" @click="drawerOpen = true"></button>
<div>
<h1>大本营答疑助手</h1>
<p>{{ statusText }}</p>
</div>
<button type="button" class="plain-btn" @click="logout">退出</button>
</header>
<section class="quota-strip">
<span>{{ user.name }}</span>
<strong>{{ user.todayUsed }}/{{ user.dailyLimit }}</strong>
</section>
<section class="chat-area">
<div v-if="messages.length === 0" class="empty-state">
<strong>可以开始提问了</strong>
<p>当前已接入大本营知识库检索链路若外部服务未配置或权限不足会在回复区提示具体原因</p>
</div>
<ChatMessage
v-for="message in messages"
:key="message.id"
:role="message.role"
:content="message.content"
:streaming="message.streaming"
/>
<div ref="bottomRef"></div>
</section>
<ChatComposer :loading="loading" :disabled="!activeSessionId" @send="send" @stop="stop" />
<SessionDrawer
:open="drawerOpen"
:sessions="sessions"
:active-session-id="activeSessionId"
@close="drawerOpen = false"
@select="selectSession"
@create="createSession"
@rename="renameSession"
@delete="deleteSession"
/>
</template>
</section>
</main>
</template>