feat: add topic summaries and growth profiles

This commit is contained in:
2026-07-31 15:34:03 +08:00
parent 884dc765ad
commit 3e60a6da42
22 changed files with 1040 additions and 5 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }) }),
};

View File

@@ -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,

View File

@@ -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;