229 lines
6.8 KiB
Vue
229 lines
6.8 KiB
Vue
<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" :class="{ 'login-mode': !user && !booting }">
|
||
<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>
|