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

561 lines
20 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, GrowthProfileResult, PeriodicReport, ShareDraft, TeacherHelpCard, 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 profileDialogOpen = ref(false);
const helpCardDialogOpen = ref(false);
const shareDraftDialogOpen = ref(false);
const growthProfile = ref<GrowthProfileResult | null>(null);
const helpCard = ref<TeacherHelpCard | null>(null);
const helpCardContent = ref("");
const shareDraft = ref<ShareDraft | null>(null);
const shareDraftContent = ref("");
const helpCardHistory = ref<TeacherHelpCard[]>([]);
const shareDraftHistory = ref<ShareDraft[]>([]);
const reportHistory = ref<PeriodicReport[]>([]);
const profileLoading = ref(false);
const finishingTopic = ref(false);
const generatingHelpCard = ref(false);
const generatingShareDraft = 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 finishCurrentTopic() {
if (!activeSessionId.value || sending.value || finishingTopic.value) return;
finishingTopic.value = true;
try {
const result = await api.finishTopic(activeSessionId.value);
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了你的成长档案" : "本主题已沉淀");
await refreshSessionList();
await refreshProfile();
} catch (error) {
handleError(error, "主题沉淀失败");
} finally {
finishingTopic.value = false;
}
}
async function generateHelpCard() {
if (!activeSessionId.value || sending.value || generatingHelpCard.value) return;
generatingHelpCard.value = true;
try {
const result = await api.generateHelpCard(activeSessionId.value);
helpCard.value = result;
helpCardContent.value = result.content;
helpCardDialogOpen.value = true;
showToast("求助卡已生成,可编辑后复制给老师");
await refreshProfile();
} catch (error) {
handleError(error, "求助卡生成失败");
} finally {
generatingHelpCard.value = false;
}
}
async function copyHelpCard() {
if (!helpCardContent.value.trim()) {
showToast("求助卡内容为空");
return;
}
try {
await copyText(helpCardContent.value);
if (helpCard.value) {
helpCard.value = await api.markHelpCardCopied(helpCard.value.id);
}
showToast("已复制,可粘贴给老师或班级群");
} catch (error) {
handleError(error, "复制失败,请手动选择文本复制");
}
}
async function generateShareDraft() {
if (!activeSessionId.value || sending.value || generatingShareDraft.value) return;
generatingShareDraft.value = true;
try {
const result = await api.generateShareDraft(activeSessionId.value);
shareDraft.value = result;
shareDraftContent.value = result.content;
shareDraftDialogOpen.value = true;
showToast("分享稿已生成,可编辑后复制");
await refreshProfile();
} catch (error) {
handleError(error, "分享稿生成失败");
} finally {
generatingShareDraft.value = false;
}
}
async function copyShareDraft() {
if (!shareDraftContent.value.trim()) {
showToast("分享稿内容为空");
return;
}
try {
await copyText(shareDraftContent.value);
if (shareDraft.value) {
shareDraft.value = await api.markShareDraftCopied(shareDraft.value.id);
}
showToast("已复制,可粘贴到班级群");
} catch (error) {
handleError(error, "复制失败,请手动选择文本复制");
}
}
async function openGrowthProfile() {
profileDialogOpen.value = true;
profileLoading.value = true;
try {
const [profileResult, helpCards, shareDrafts, reports] = await Promise.all([
api.growthProfile(),
api.helpCards(10),
api.shareDrafts(10),
api.periodicReports(10),
]);
growthProfile.value = profileResult;
helpCardHistory.value = helpCards;
shareDraftHistory.value = shareDrafts;
reportHistory.value = reports;
} catch (error) {
handleError(error, "成长档案加载失败");
} finally {
profileLoading.value = false;
}
}
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);
}
async function copyText(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
</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"
:finishing="finishingTopic"
:generating-help-card="generatingHelpCard"
:generating-share-draft="generatingShareDraft"
@finish-topic="finishCurrentTopic"
@open-profile="openGrowthProfile"
@generate-help-card="generateHelpCard"
@generate-share-draft="generateShareDraft"
/>
<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="profileDialogOpen" title="我的实修档案" labelled-by="growth-profile-title" @close="profileDialogOpen = false">
<section class="growth-profile-dialog" :aria-busy="profileLoading">
<p v-if="profileLoading" class="profile-empty">正在加载成长档案...</p>
<template v-else-if="growthProfile?.profile">
<pre>{{ growthProfile.profile.profileText }}</pre>
<div class="growth-profile-fields">
<p v-if="growthProfile.profile.recurringTopics"><strong>反复议题</strong>{{ growthProfile.profile.recurringTopics }}</p>
<p v-if="growthProfile.profile.commonEmotions"><strong>常见情绪</strong>{{ growthProfile.profile.commonEmotions }}</p>
<p v-if="growthProfile.profile.bodyPatterns"><strong>身体感受</strong>{{ growthProfile.profile.bodyPatterns }}</p>
<p v-if="growthProfile.profile.homeworkDone"><strong>做过的功课</strong>{{ growthProfile.profile.homeworkDone }}</p>
<p v-if="growthProfile.profile.recentProgress"><strong>最近进展</strong>{{ growthProfile.profile.recentProgress }}</p>
</div>
</template>
<p v-else class="profile-empty">还没有成长档案你可以在完成一次主题对话后点击沉淀本主题</p>
<div v-if="growthProfile?.recentSummaries.length" class="recent-topic-summaries">
<h3>最近主题沉淀</h3>
<article v-for="item in growthProfile.recentSummaries" :key="item.id">
<time>{{ item.generatedAt }}</time>
<p>{{ item.summary }}</p>
</article>
</div>
<div v-if="reportHistory.length" class="recent-topic-summaries periodic-report-list">
<h3>周期实修报告</h3>
<article v-for="item in reportHistory" :key="`report-${item.id}`">
<time>{{ item.reportTypeLabel }} · {{ item.periodStart }} {{ item.periodEnd }} · {{ item.generatedAt }}</time>
<p class="report-title">{{ item.title }}</p>
<pre>{{ item.content }}</pre>
</article>
</div>
<div v-if="helpCardHistory.length" class="recent-topic-summaries">
<h3>最近老师求助卡</h3>
<article v-for="item in helpCardHistory" :key="`help-${item.id}`">
<time>{{ item.createdAt }} · {{ item.copied ? '已复制' : '未复制' }}</time>
<p>{{ item.content.slice(0, 140) }}{{ item.content.length > 140 ? '…' : '' }}</p>
</article>
</div>
<div v-if="shareDraftHistory.length" class="recent-topic-summaries">
<h3>最近班级分享稿</h3>
<article v-for="item in shareDraftHistory" :key="`share-${item.id}`">
<time>{{ item.createdAt }} · {{ item.copied ? '已复制' : '未复制' }}</time>
<p>{{ item.content.slice(0, 140) }}{{ item.content.length > 140 ? '…' : '' }}</p>
</article>
</div>
</section>
</AppDialog>
<AppDialog v-if="helpCardDialogOpen" title="老师求助卡" labelled-by="help-card-title" @close="helpCardDialogOpen = false">
<section class="help-card-dialog">
<p>这不是转人工工单系统不会自动发送给老师你可以按真实情况删改后自行复制给老师或班级群确认</p>
<textarea v-model="helpCardContent" aria-label="求助卡内容" />
</section>
<template #footer>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="helpCardDialogOpen = false">关闭</button>
<button type="button" class="dialog-primary" @click="copyHelpCard">复制求助卡</button>
</div>
</template>
</AppDialog>
<AppDialog v-if="shareDraftDialogOpen" title="班级分享稿" labelled-by="share-draft-title" @close="shareDraftDialogOpen = false">
<section class="help-card-dialog">
<p>这只是分享草稿系统不会自动发送到任何群请删掉不想公开的隐私内容并按自己的真实状态修改后再复制</p>
<textarea v-model="shareDraftContent" aria-label="班级分享稿内容" />
</section>
<template #footer>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="shareDraftDialogOpen = false">关闭</button>
<button type="button" class="dialog-primary" @click="copyShareDraft">复制分享稿</button>
</div>
</template>
</AppDialog>
<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>