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

731 lines
26 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 PersonalCenterDialog from "./components/PersonalCenterDialog.vue";
import SessionDrawer from "./components/SessionDrawer.vue";
import SessionQuota from "./components/SessionQuota.vue";
import { ApiError, api, clearToken, getToken, saveToken, streamChat } from "./services/api";
import type { ChatMessage as ApiMessage, ChatSession, PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
const activeSessionId = ref<number | null>(null);
const composerKey = ref(0);
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 personalCenterOpen = ref(false);
const personalCenterSection = ref<"overview" | "review" | "records">("overview");
const helpCardDialogOpen = ref(false);
const shareDraftDialogOpen = ref(false);
const practiceReview = ref<PracticeReviewResult | 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 personalCenterLoading = ref(false);
const cardOperationPending = 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 feedbackDialogOpen = ref(false);
const feedbackMessageId = ref<number | null>(null);
const feedbackContent = ref("");
const feedbackSubmitting = ref(false);
const activeAbortController = ref<AbortController | null>(null);
let toastTimer: number | null = null;
let settlementPollVersion = 0;
onMounted(bootstrap);
async function bootstrap() {
const search = new URLSearchParams(window.location.search);
const ssoCode = search.get("sso_code")?.trim() || "";
if (ssoCode) {
search.delete("sso_code");
const query = search.toString();
window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`);
statusText.value = "正在完成免登录";
try {
const result = await api.exchangeSso(ssoCode);
saveToken(result.token);
if (result.returnUrl) window.sessionStorage.setItem("ai-kb-sso-return-url", result.returnUrl);
user.value = result.user;
await loadSessions();
statusText.value = "已连接大本营答疑服务";
} catch (error) {
clearUserState();
showToast(error instanceof Error ? `免登录失败:${error.message}` : "免登录失败,请重新进入或使用手机号登录");
} finally {
booting.value = false;
}
return;
}
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(() => {
settlementPollVersion += 1;
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;
composerKey.value += 1;
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) {
const sessionId = activeSessionId.value;
if (!sessionId || 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;
complete(true);
await messageList.value?.scrollToMessage(userMessage.id);
await runGeneration(sessionId, message, assistantIndex, false);
}
async function retryMessage(messageId: string) {
const sessionId = activeSessionId.value;
if (!sessionId || sending.value) return;
const assistantIndex = messages.value.findIndex((item) => item.id === messageId);
const assistantMessage = messages.value[assistantIndex];
if (!assistantMessage?.retryQuestion) return;
await messageList.value?.scrollToMessage(messageId);
await runGeneration(sessionId, assistantMessage.retryQuestion, assistantIndex, true);
}
async function runGeneration(sessionId: number, message: string, assistantIndex: number, retry: boolean) {
const currentAssistant = () => messages.value[assistantIndex];
currentAssistant().content = "";
currentAssistant().reasoning = "";
currentAssistant().showReasoning = false;
currentAssistant().errorMessage = undefined;
currentAssistant().streaming = true;
sending.value = true;
followingOutput.value = true;
activeAbortController.value = new AbortController();
let hasContent = false;
try {
await streamChat(
sessionId,
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,
retry,
);
currentAssistant().streaming = false;
currentAssistant().retryQuestion = undefined;
currentAssistant().createdAt = new Date().toISOString();
try {
const latestHistory = await api.history(sessionId);
const latestAssistant = [...latestHistory].reverse().find((item) => item.role === "assistant");
if (latestAssistant) currentAssistant().id = String(latestAssistant.id);
} catch {
// 回答已经成功,历史记录偶发刷新失败不应将本次回答标记为失败。
}
await refreshSessionList();
await refreshProfile();
} catch (error) {
currentAssistant().streaming = false;
if (error instanceof DOMException && error.name === "AbortError") {
currentAssistant().content = currentAssistant().content || "已停止生成";
currentAssistant().retryQuestion = undefined;
} else {
currentAssistant().errorMessage = chatErrorMessage(error);
currentAssistant().retryQuestion = message;
showToast("本次回答未完成,可以点击重试");
}
} finally {
sending.value = false;
activeAbortController.value = null;
}
}
function openFeedback(messageId: string) {
const parsed = Number(messageId);
if (!Number.isInteger(parsed)) return;
feedbackMessageId.value = parsed;
feedbackContent.value = "";
feedbackDialogOpen.value = true;
}
async function submitFeedback() {
const content = feedbackContent.value.trim();
if (!feedbackMessageId.value || !content || feedbackSubmitting.value) return;
feedbackSubmitting.value = true;
try {
await api.submitFeedback(feedbackMessageId.value, content);
feedbackDialogOpen.value = false;
showToast("感谢反馈,管理员会查看你提交的问题");
} catch (error) {
showToast(error instanceof Error ? error.message : "反馈提交失败");
} finally {
feedbackSubmitting.value = false;
}
}
function chatErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "";
if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多AI 服务暂时繁忙。";
if (/timeout|超时/i.test(message)) return "等待 AI 回复超时,请稍后重试。";
if (/network|网络|连接/i.test(message)) return "网络连接不稳定,本次回答没有完成。";
return "AI 回答生成失败,本次不会扣除成功回答额度。";
}
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);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了近期实修回顾" : "本主题已沉淀");
} else {
showToast("主题已结束,实修记录正在后台沉淀,可以继续使用其他功能");
void watchTopicSettlement(result.summary.id);
}
await refreshSessionList();
await refreshProfile();
} catch (error) {
handleError(error, "主题沉淀失败");
} finally {
finishingTopic.value = false;
}
}
async function watchTopicSettlement(summaryId: number) {
const version = ++settlementPollVersion;
for (let attempt = 0; attempt < 40; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 3000));
if (version !== settlementPollVersion || !user.value) return;
try {
const result = await api.topicSettlement(summaryId);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "实修记录已沉淀,并更新了近期实修回顾" : "实修记录已沉淀完成");
await refreshProfile();
return;
}
if (result.settlementStatus === "failed") {
showToast("实修记录暂时沉淀失败,系统已保留对话,可稍后重试");
return;
}
} catch {
// 轮询失败不影响聊天;下一轮继续查询持久化任务状态。
}
}
}
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 copyHistoryHelpCard(card: TeacherHelpCard) {
try {
await copyText(card.content);
const updated = await api.markHelpCardCopied(card.id);
helpCardHistory.value = helpCardHistory.value.map((item) => item.id === updated.id ? updated : item);
showToast("求助卡已复制,可再次粘贴给老师");
} catch (error) {
handleError(error, "复制失败,请稍后重试");
}
}
async function copyHistoryShareDraft(draft: ShareDraft) {
try {
await copyText(draft.content);
const updated = await api.markShareDraftCopied(draft.id);
shareDraftHistory.value = shareDraftHistory.value.map((item) => item.id === updated.id ? updated : item);
showToast("分享稿已复制,可再次粘贴到班级群");
} catch (error) {
handleError(error, "复制失败,请稍后重试");
}
}
async function deleteHistoryHelpCard(cardId: number, done: (success: boolean) => void) {
if (cardOperationPending.value) return;
cardOperationPending.value = true;
try {
await api.deleteHelpCard(cardId);
helpCardHistory.value = helpCardHistory.value.filter((item) => item.id !== cardId);
if (helpCard.value?.id === cardId) helpCard.value = null;
showToast("求助卡已删除");
done(true);
} catch (error) {
done(false);
handleError(error, "求助卡删除失败");
} finally {
cardOperationPending.value = false;
}
}
async function deleteHistoryShareDraft(draftId: number, done: (success: boolean) => void) {
if (cardOperationPending.value) return;
cardOperationPending.value = true;
try {
await api.deleteShareDraft(draftId);
shareDraftHistory.value = shareDraftHistory.value.filter((item) => item.id !== draftId);
if (shareDraft.value?.id === draftId) shareDraft.value = null;
showToast("分享稿已删除");
done(true);
} catch (error) {
done(false);
handleError(error, "分享稿删除失败");
} finally {
cardOperationPending.value = false;
}
}
async function openPersonalCenter(section: "overview" | "review" | "records" = "overview") {
personalCenterSection.value = section;
personalCenterOpen.value = true;
personalCenterLoading.value = true;
try {
const [latestUser, profileResult, helpCards, shareDrafts, reports] = await Promise.all([
api.profile(),
api.practiceReview(),
api.helpCards(10),
api.shareDrafts(10),
api.periodicReports(10),
]);
user.value = latestUser;
practiceReview.value = profileResult;
helpCardHistory.value = helpCards;
shareDraftHistory.value = shareDrafts;
reportHistory.value = reports;
} catch (error) {
handleError(error, "个人中心加载失败");
} finally {
personalCenterLoading.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;
personalCenterOpen.value = false;
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"
@open-personal-center="openPersonalCenter('overview')"
/>
<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="openPersonalCenter('review')"
@generate-help-card="generateHelpCard"
@generate-share-draft="generateShareDraft"
/>
<MessageList
ref="messageList"
:messages="messages"
:loading-session="loadingSession"
@follow-change="followingOutput = $event"
@retry="retryMessage"
@feedback="openFeedback"
/>
<ChatComposer :key="composerKey" :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="feedbackDialogOpen" title="反馈这次回答" labelled-by="feedback-dialog-title" @close="feedbackDialogOpen = false">
<section class="feedback-dialog">
<p>请简单描述这次回答存在的问题管理员可以结合当时的对话记录查看</p>
<textarea v-model="feedbackContent" maxlength="200" rows="5" aria-label="反馈内容" placeholder="例如:回答没有解决我的问题、内容不准确……" />
<div class="feedback-dialog-footer"><span>{{ feedbackContent.length }}/200</span><button type="button" :disabled="!feedbackContent.trim() || feedbackSubmitting" @click="submitFeedback">{{ feedbackSubmitting ? "提交中" : "提交反馈" }}</button></div>
</section>
</AppDialog>
<PersonalCenterDialog
v-if="personalCenterOpen && user"
:user="user"
:initial-section="personalCenterSection"
:loading="personalCenterLoading"
:practice-review="practiceReview"
:reports="reportHistory"
:help-cards="helpCardHistory"
:share-drafts="shareDraftHistory"
:operation-pending="cardOperationPending"
@close="personalCenterOpen = false"
@logout="personalCenterOpen = false; logoutDialogOpen = true"
@copy-help-card="copyHistoryHelpCard"
@copy-share-draft="copyHistoryShareDraft"
@delete-help-card="deleteHistoryHelpCard"
@delete-share-draft="deleteHistoryShareDraft"
/>
<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>