feat: add topic summaries and growth profiles
This commit is contained in:
@@ -9,7 +9,7 @@ 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";
|
||||
import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, UserProfile } from "./types/api";
|
||||
|
||||
const user = ref<UserProfile | null>(null);
|
||||
const sessions = ref<ChatSession[]>([]);
|
||||
@@ -22,6 +22,10 @@ const loadingSession = ref(false);
|
||||
const loadingSessions = ref(false);
|
||||
const sessionOperationPending = ref(false);
|
||||
const logoutDialogOpen = ref(false);
|
||||
const profileDialogOpen = ref(false);
|
||||
const growthProfile = ref<GrowthProfileResult | null>(null);
|
||||
const profileLoading = ref(false);
|
||||
const finishingTopic = ref(false);
|
||||
const statusText = ref("连接后端中");
|
||||
const toastText = ref("");
|
||||
const followingOutput = ref(true);
|
||||
@@ -195,6 +199,33 @@ async function stop() {
|
||||
}
|
||||
}
|
||||
|
||||
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 openGrowthProfile() {
|
||||
profileDialogOpen.value = true;
|
||||
profileLoading.value = true;
|
||||
try {
|
||||
growthProfile.value = await api.growthProfile();
|
||||
} 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;
|
||||
@@ -304,7 +335,14 @@ function showToast(message: string) {
|
||||
<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" />
|
||||
<SessionQuota
|
||||
:used="user.todayUsed"
|
||||
:limit="user.dailyLimit"
|
||||
:entitlement="user.entitlement"
|
||||
:finishing="finishingTopic"
|
||||
@finish-topic="finishCurrentTopic"
|
||||
@open-profile="openGrowthProfile"
|
||||
/>
|
||||
<MessageList
|
||||
ref="messageList"
|
||||
:messages="messages"
|
||||
@@ -329,6 +367,30 @@ function showToast(message: string) {
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
|
||||
<p class="confirm-copy">确定退出当前账号吗?</p>
|
||||
<template #footer>
|
||||
|
||||
@@ -8,6 +8,12 @@ const props = defineProps<{
|
||||
used: number;
|
||||
limit: number;
|
||||
entitlement?: UserEntitlementSummary | null;
|
||||
finishing?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
finishTopic: [];
|
||||
openProfile: [];
|
||||
}>();
|
||||
|
||||
const hasEntitlement = computed(() => Boolean(props.entitlement));
|
||||
@@ -25,6 +31,19 @@ const limitText = computed(() => displayLimit.value === null ? "不限" : String
|
||||
<span>{{ title }}</span>
|
||||
<small v-if="hasEntitlement">{{ entitlement?.name }}</small>
|
||||
</div>
|
||||
<strong>{{ displayUsed }}/{{ limitText }}</strong>
|
||||
<div class="session-quota-actions">
|
||||
<button
|
||||
v-if="entitlement?.enableGrowthProfile"
|
||||
type="button"
|
||||
class="quota-link"
|
||||
@click="$emit('openProfile')"
|
||||
>
|
||||
我的档案
|
||||
</button>
|
||||
<button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
|
||||
{{ finishing ? '沉淀中' : '沉淀本主题' }}
|
||||
</button>
|
||||
<strong>{{ displayUsed }}/{{ limitText }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { ApiResponse, CaptchaResult, ChatMessage, ChatSession, LoginResult, UserProfile } from "../types/api";
|
||||
import type {
|
||||
ApiResponse,
|
||||
CaptchaResult,
|
||||
ChatMessage,
|
||||
ChatSession,
|
||||
FinishTopicResult,
|
||||
GrowthProfileResult,
|
||||
LoginResult,
|
||||
UserProfile,
|
||||
} from "../types/api";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const TOKEN_KEY = "ai-kb-user-token";
|
||||
@@ -76,6 +85,8 @@ export const api = {
|
||||
renameSession: (sessionId: number, title: string) =>
|
||||
request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }),
|
||||
deleteSession: (sessionId: number) => request<null>(`/chat/session/${sessionId}`, { method: "DELETE" }),
|
||||
finishTopic: (sessionId: number) => request<FinishTopicResult>(`/chat/session/${sessionId}/topic/finish`, { method: "POST", body: JSON.stringify({}) }),
|
||||
growthProfile: () => request<GrowthProfileResult>("/user/growth-profile"),
|
||||
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
||||
};
|
||||
|
||||
|
||||
@@ -1230,6 +1230,30 @@ textarea:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-quota-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.quota-link {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(31, 107, 82, 0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #2f6d59;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quota-link:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.session-quota svg,
|
||||
.session-quota strong {
|
||||
color: var(--chat-brand-dark);
|
||||
@@ -1672,6 +1696,72 @@ textarea:focus-visible {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.growth-profile-dialog {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.growth-profile-dialog pre {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--chat-border);
|
||||
border-radius: 14px;
|
||||
background: var(--chat-brand-soft);
|
||||
color: var(--chat-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.growth-profile-fields,
|
||||
.recent-topic-summaries {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.growth-profile-fields p,
|
||||
.profile-empty {
|
||||
margin: 0;
|
||||
color: var(--chat-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.growth-profile-fields strong {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: var(--chat-text);
|
||||
}
|
||||
|
||||
.recent-topic-summaries h3 {
|
||||
margin: 4px 0 0;
|
||||
color: var(--chat-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.recent-topic-summaries article {
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--chat-border);
|
||||
border-radius: 13px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.recent-topic-summaries time {
|
||||
color: var(--chat-weak);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recent-topic-summaries p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--chat-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -1727,6 +1817,8 @@ body.drawer-open { overflow: hidden; }
|
||||
.user-summary { max-width: 72px; font-size: 9px; }
|
||||
.header-logout-button { min-width: 48px; padding: 0 5px; }
|
||||
.session-quota { margin-right: 12px; margin-left: 12px; }
|
||||
.session-quota-actions { gap: 5px; }
|
||||
.quota-link { padding: 0 6px; font-size: 11px; }
|
||||
.message-list { padding-right: 12px; padding-left: 12px; }
|
||||
.chat-composer { padding-right: 10px; padding-left: 10px; }
|
||||
.composer-send,
|
||||
|
||||
@@ -32,6 +32,41 @@ export interface UserEntitlementSummary {
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface TopicSummary {
|
||||
id: number;
|
||||
topicSessionId: number;
|
||||
summary: string;
|
||||
recommendedHomework?: string | null;
|
||||
nextObservation?: string | null;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface GrowthProfile {
|
||||
id: number;
|
||||
userId: number;
|
||||
profileText: string;
|
||||
recurringTopics?: string | null;
|
||||
commonEmotions?: string | null;
|
||||
bodyPatterns?: string | null;
|
||||
relationPatterns?: string | null;
|
||||
homeworkDone?: string | null;
|
||||
effectiveHomework?: string | null;
|
||||
recentProgress?: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GrowthProfileResult {
|
||||
profile: GrowthProfile | null;
|
||||
recentSummaries: TopicSummary[];
|
||||
}
|
||||
|
||||
export interface FinishTopicResult {
|
||||
topic: Record<string, unknown>;
|
||||
summary: TopicSummary & Record<string, unknown>;
|
||||
profile: GrowthProfile | null;
|
||||
growthProfileEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
expiredAt: string;
|
||||
|
||||
Reference in New Issue
Block a user