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

@@ -1571,6 +1571,38 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div><span>消息数</span><strong>{{ chatDetail.session.messageCount }}</strong></div>
</section>
<h3 class="detail-title">主题会话</h3>
<el-collapse v-if="chatDetail.topics?.length" class="topic-summary-collapse">
<el-collapse-item
v-for="topic in chatDetail.topics"
:key="topic.id"
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>核心问题{{ topic.coreQuestion }}</span>
<span>消息数{{ topic.messageCount }}</span>
<span>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span>
<span>额度{{ topic.quotaDeducted ? '已计入' : '不计入' }}</span>
<span>开始{{ topic.startedAt }}</span>
<span>结束{{ topic.endedAt || '-' }}</span>
</div>
<template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre>
<div class="topic-summary-fields">
<p v-if="topic.summary.mainEvents"><strong>主要事件</strong>{{ topic.summary.mainEvents }}</p>
<p v-if="topic.summary.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</p>
<p v-if="topic.summary.recommendedHomework"><strong>推荐功课</strong>{{ topic.summary.recommendedHomework }}</p>
<p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p>
</div>
</template>
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该会话暂无主题记录" :image-size="64" />
<h3 class="detail-title">完整对话</h3>
<AdminPagination
class="chat-detail-pagination top"

View File

@@ -2042,6 +2042,64 @@ textarea {
font-size: 16px;
}
.topic-summary-collapse {
margin-bottom: 14px;
}
.topic-summary-card {
display: grid;
gap: 12px;
}
.topic-summary-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
color: #667a73;
font-size: 12px;
}
.topic-summary-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-summary-card pre {
margin: 0;
padding: 12px;
border: 1px solid #dfe8e5;
border-radius: 10px;
background: #f8fbfa;
color: #34453f;
font: inherit;
font-size: 13px;
line-height: 1.75;
white-space: pre-wrap;
}
.topic-summary-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.topic-summary-fields p {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: #f6f8f7;
color: #53645e;
font-size: 13px;
line-height: 1.65;
}
.topic-summary-fields strong {
display: block;
margin-bottom: 4px;
color: #203b32;
}
.conversation-list {
display: grid;
gap: 12px;

View File

@@ -396,6 +396,42 @@ export interface ChatDetail {
messages: ChatMessageRecord[];
messagesPage?: PageResult<ChatMessageRecord>;
aiLogs: AiLogRecord[];
topics?: TopicSessionRecord[];
}
export interface TopicSummaryRecord {
id: number;
topicSessionId: number;
userId: number;
summary: string;
mainEvents?: string | null;
emotions?: string | null;
bodyFeelings?: string | null;
beliefs?: string | null;
recommendedHomework?: string | null;
insights?: string | null;
nextObservation?: string | null;
modelName?: string | null;
status: string;
errorMessage?: string | null;
generatedAt: string;
}
export interface TopicSessionRecord {
id: number;
userId: number;
chatSessionId: number;
title: string;
coreQuestion: string;
status: string;
messageCount: number;
tokenInput: number;
tokenOutput: number;
quotaDeducted: boolean;
recommendedHomework?: string | null;
startedAt: string;
endedAt?: string | null;
summary?: TopicSummaryRecord | null;
}
export interface QuestionInsightSummary {

View File

@@ -0,0 +1,102 @@
"""add topic summaries and growth profiles
Revision ID: 0015_growth_profiles
Revises: 0014_entitlements_topics
"""
from alembic import op
import sqlalchemy as sa
revision = "0015_growth_profiles"
down_revision = "0014_entitlements_topics"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_topic_summary" not in tables:
op.create_table(
"sys_topic_summary",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("main_events", sa.Text(), nullable=True),
sa.Column("emotions", sa.Text(), nullable=True),
sa.Column("body_feelings", sa.Text(), nullable=True),
sa.Column("beliefs", sa.Text(), nullable=True),
sa.Column("recommended_homework", sa.Text(), nullable=True),
sa.Column("insights", sa.Text(), nullable=True),
sa.Column("next_observation", sa.Text(), nullable=True),
sa.Column("model_name", sa.String(100), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="success"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("generated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("topic_session_id", name="uq_sys_topic_summary_topic"),
)
op.create_index("ix_sys_topic_summary_topic_session_id", "sys_topic_summary", ["topic_session_id"])
op.create_index("ix_sys_topic_summary_user_id", "sys_topic_summary", ["user_id"])
op.create_index("ix_sys_topic_summary_status", "sys_topic_summary", ["status"])
if "sys_user_growth_profile" not in tables:
op.create_table(
"sys_user_growth_profile",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("profile_text", sa.Text(), nullable=False),
sa.Column("recurring_topics", sa.Text(), nullable=True),
sa.Column("common_emotions", sa.Text(), nullable=True),
sa.Column("body_patterns", sa.Text(), nullable=True),
sa.Column("relation_patterns", sa.Text(), nullable=True),
sa.Column("homework_done", sa.Text(), nullable=True),
sa.Column("effective_homework", sa.Text(), nullable=True),
sa.Column("recent_progress", sa.Text(), nullable=True),
sa.Column("last_topic_summary_id", sa.BigInteger(), nullable=True),
sa.Column("updated_by", sa.String(30), nullable=False, server_default="system"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_id", name="uq_sys_user_growth_profile_user"),
)
op.create_index("ix_sys_user_growth_profile_user_id", "sys_user_growth_profile", ["user_id"])
if "sys_growth_profile_revision" not in tables:
op.create_table(
"sys_growth_profile_revision",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("profile_id", sa.BigInteger(), sa.ForeignKey("sys_user_growth_profile.id"), nullable=False),
sa.Column("topic_summary_id", sa.BigInteger(), nullable=True),
sa.Column("before_json", sa.Text(), nullable=True),
sa.Column("after_json", sa.Text(), nullable=False),
sa.Column("reason", sa.String(50), nullable=False, server_default="topic_summary"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_growth_profile_revision_user_id", "sys_growth_profile_revision", ["user_id"])
op.create_index("ix_sys_growth_profile_revision_profile_id", "sys_growth_profile_revision", ["profile_id"])
op.create_index("ix_sys_growth_profile_revision_topic_summary_id", "sys_growth_profile_revision", ["topic_summary_id"])
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_growth_profile_revision" in tables:
op.drop_index("ix_sys_growth_profile_revision_topic_summary_id", table_name="sys_growth_profile_revision")
op.drop_index("ix_sys_growth_profile_revision_profile_id", table_name="sys_growth_profile_revision")
op.drop_index("ix_sys_growth_profile_revision_user_id", table_name="sys_growth_profile_revision")
op.drop_table("sys_growth_profile_revision")
if "sys_user_growth_profile" in tables:
op.drop_index("ix_sys_user_growth_profile_user_id", table_name="sys_user_growth_profile")
op.drop_table("sys_user_growth_profile")
if "sys_topic_summary" in tables:
op.drop_index("ix_sys_topic_summary_status", table_name="sys_topic_summary")
op.drop_index("ix_sys_topic_summary_user_id", table_name="sys_topic_summary")
op.drop_index("ix_sys_topic_summary_topic_session_id", table_name="sys_topic_summary")
op.drop_table("sys_topic_summary")

View File

@@ -15,11 +15,13 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import TopicSummary
from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User
from app.api.pagination import page_result
from app.services.question_insight_service import QuestionInsightService
from app.services.growth_profile_service import topic_dict, topic_summary_dict
router = APIRouter()
@@ -135,6 +137,7 @@ def chat_detail(
.where(AiRequestLog.session_id == session_id)
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
).all()
topics = _topic_rows(db, session_id)
return api_success(
{
"session": _chat_row_dict(session, user),
@@ -146,6 +149,7 @@ def chat_detail(
page_size=messagePageSize,
),
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
"topics": topics,
}
)
@@ -317,6 +321,22 @@ def _message_dict(message: ChatMessage) -> dict:
}
def _topic_rows(db: Session, session_id: int) -> list[dict]:
rows = db.execute(
select(TopicSession, TopicSummary)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id, isouter=True)
.where(TopicSession.chat_session_id == session_id)
.order_by(TopicSession.started_at.asc(), TopicSession.id.asc())
).all()
return [
{
**topic_dict(topic),
"summary": topic_summary_dict(summary) if summary else None,
}
for topic, summary in rows
]
def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chunks: bool = True) -> dict:
data = {
"id": log.id,

View File

@@ -30,6 +30,7 @@ from app.services.chat_queue_runtime import (
)
from app.services.chat_queue_service import load_chat_queue_config
from app.services.chat_stream_service import ChatStreamService
from app.services.growth_profile_service import GrowthProfileService
from app.services.reasoning_policy_service import ReasoningPolicyService
router = APIRouter()
@@ -85,6 +86,16 @@ def delete_session(
return api_success()
@router.post("/session/{session_id}/topic/finish")
def finish_topic(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
session = ChatService._get_user_session(db, current_user, session_id)
return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session))
@router.post("/completions")
def completions(
payload: ChatCompletionRequest,

View File

@@ -9,6 +9,7 @@ from app.core.responses import api_success
from app.models.user import User
from app.schemas.user import UserProfile
from app.services.entitlement_service import EntitlementService, entitlement_dict
from app.services.growth_profile_service import GrowthProfileService, growth_profile_dict
from app.services.topic_session_service import TopicSessionService
router = APIRouter()
@@ -27,3 +28,28 @@ def profile(
)
data["entitlement"] = entitlement_dict(view)
return api_success(data)
@router.get("/growth-profile")
def growth_profile(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
profile = GrowthProfileService.get_growth_profile(db, current_user.id)
summaries = GrowthProfileService.recent_topic_summaries(db, current_user.id, limit=10)
return api_success(
{
"profile": growth_profile_dict(profile),
"recentSummaries": [
{
"id": item.id,
"topicSessionId": item.topic_session_id,
"summary": item.summary,
"recommendedHomework": item.recommended_homework,
"nextObservation": item.next_observation,
"generatedAt": item.generated_at,
}
for item in summaries
],
}
)

View File

@@ -3,6 +3,7 @@ from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.base import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
@@ -30,6 +31,7 @@ __all__ = [
"ChatMessage",
"ChatSession",
"EntitlementPlan",
"GrowthProfileRevision",
"Knowledge",
"KnowledgeCard",
"KnowledgeChunk",
@@ -49,11 +51,13 @@ __all__ = [
"LogRetentionPolicy",
"StorageSnapshot",
"TopicSession",
"TopicSummary",
"Prompt",
"Role",
"SystemConfig",
"User",
"UserEntitlement",
"UserEntitlementLog",
"UserGrowthProfile",
"UserKnowledgePermission",
]

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class TopicSummary(Base):
__tablename__ = "sys_topic_summary"
__table_args__ = (UniqueConstraint("topic_session_id", name="uq_sys_topic_summary_topic"),)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
topic_session_id: Mapped[int] = mapped_column(ForeignKey("sys_topic_session.id"), index=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
main_events: Mapped[str | None] = mapped_column(Text, nullable=True)
emotions: Mapped[str | None] = mapped_column(Text, nullable=True)
body_feelings: Mapped[str | None] = mapped_column(Text, nullable=True)
beliefs: Mapped[str | None] = mapped_column(Text, nullable=True)
recommended_homework: Mapped[str | None] = mapped_column(Text, nullable=True)
insights: Mapped[str | None] = mapped_column(Text, nullable=True)
next_observation: Mapped[str | None] = mapped_column(Text, nullable=True)
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="success", index=True, nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
generated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class UserGrowthProfile(Base):
__tablename__ = "sys_user_growth_profile"
__table_args__ = (UniqueConstraint("user_id", name="uq_sys_user_growth_profile_user"),)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
profile_text: Mapped[str] = mapped_column(Text, default="", nullable=False)
recurring_topics: Mapped[str | None] = mapped_column(Text, nullable=True)
common_emotions: Mapped[str | None] = mapped_column(Text, nullable=True)
body_patterns: Mapped[str | None] = mapped_column(Text, nullable=True)
relation_patterns: Mapped[str | None] = mapped_column(Text, nullable=True)
homework_done: Mapped[str | None] = mapped_column(Text, nullable=True)
effective_homework: Mapped[str | None] = mapped_column(Text, nullable=True)
recent_progress: Mapped[str | None] = mapped_column(Text, nullable=True)
last_topic_summary_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_by: Mapped[str] = mapped_column(String(30), default="system", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class GrowthProfileRevision(Base):
__tablename__ = "sys_growth_profile_revision"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
profile_id: Mapped[int] = mapped_column(ForeignKey("sys_user_growth_profile.id"), index=True, nullable=False)
topic_summary_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
before_json: Mapped[str | None] = mapped_column(Text, nullable=True)
after_json: Mapped[str] = mapped_column(Text, nullable=False)
reason: Mapped[str] = mapped_column(String(50), default="topic_summary", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -13,6 +13,7 @@ from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService
from app.services.entitlement_service import EntitlementService
from app.services.external_errors import ExternalServiceError
from app.services.growth_profile_service import GrowthProfileService
from app.services.chat_context_service import ChatContextService
from app.services.model_service import ModelClientService
from app.services.rag_service import RagService
@@ -124,6 +125,10 @@ class ChatService:
summary_result = ChatContextService.update_summary(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
if growth_context:
context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
# 构建 prompt传入历史 + 摘要)
rag_result = RagService.build_result(
@@ -132,6 +137,7 @@ class ChatService:
session_summary=session.summary,
summary_up_to_message_id=session.summary_up_to_message_id,
context_trace=context_trace,
growth_context=growth_context,
)
completion = ModelClientService.complete(db, rag_result)
except ExternalServiceError as exc:

View File

@@ -18,6 +18,7 @@ from app.services.chat_service import ChatService, _title_from_question
from app.services.chat_context_service import ChatContextService
from app.services.entitlement_service import EntitlementService
from app.services.external_errors import ExternalServiceError
from app.services.growth_profile_service import GrowthProfileService
from app.services.human_attention_service import HumanAttentionService
from app.services.model_stream_service import ModelStreamService
from app.services.rag_async_service import AsyncRagService
@@ -73,6 +74,10 @@ class ChatStreamService:
)
summary_result = ChatContextService.update_summary(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
if growth_context:
context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
started_at = perf_counter()
rag_result = None
@@ -88,6 +93,7 @@ class ChatStreamService:
getattr(session, "summary", None),
getattr(session, "summary_up_to_message_id", None),
context_trace,
growth_context,
)
model_response = ModelStreamService.stream(db, rag_result)
for chunk in model_response.chunks:
@@ -229,6 +235,10 @@ class ChatStreamService:
)
summary_result = await ChatContextService.update_summary_async(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
if growth_context:
context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
started_at = perf_counter()
rag_result = None
@@ -245,6 +255,7 @@ class ChatStreamService:
summary_up_to_message_id=getattr(session, "summary_up_to_message_id", None),
session_id=session.id,
context_trace=context_trace,
growth_context=growth_context,
)
model_response = ModelStreamService.stream_async(db, rag_result)
async for chunk in model_response.chunks:
@@ -425,6 +436,7 @@ def _build_rag_result(
summary: str | None,
summary_up_to_message_id: int | None,
context_trace: list[dict] | None = None,
growth_context: str | None = None,
):
parameters = signature(RagService.build_result).parameters
if "history" in parameters:
@@ -436,5 +448,6 @@ def _build_rag_result(
session_summary=summary,
summary_up_to_message_id=summary_up_to_message_id,
context_trace=context_trace,
growth_context=growth_context,
)
return RagService.build_result(db, user, question)

View File

@@ -0,0 +1,335 @@
from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile
from app.models.user import User
from app.services.entitlement_service import EntitlementService
from app.services.external_errors import ExternalServiceError
from app.services.model_service import ModelClientService
from app.services.topic_session_service import TopicSessionService
class GrowthProfileService:
@staticmethod
def finish_active_topic(db: Session, *, user: User, session: ChatSession, force: bool = False) -> dict:
topic = TopicSessionService.active_for_session(db, user=user, session=session)
if topic is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前会话没有进行中的主题")
summary = GrowthProfileService.generate_topic_summary(db, user=user, topic=topic, force=force)
topic.status = "completed"
topic.ended_at = _now()
topic.recommended_homework = summary.recommended_homework
db.add(topic)
entitlement = EntitlementService.active_entitlement(db, user)
profile = None
if entitlement.enable_growth_profile:
profile = GrowthProfileService.update_growth_profile(db, user=user, topic_summary=summary)
db.commit()
db.refresh(topic)
db.refresh(summary)
if profile is not None:
db.refresh(profile)
return {
"topic": topic_dict(topic),
"summary": topic_summary_dict(summary),
"profile": growth_profile_dict(profile) if profile is not None else None,
"growthProfileEnabled": entitlement.enable_growth_profile,
}
@staticmethod
def generate_topic_summary(db: Session, *, user: User, topic: TopicSession, force: bool = False) -> TopicSummary:
existing = db.scalar(select(TopicSummary).where(TopicSummary.topic_session_id == topic.id))
if existing is not None and not force:
return existing
messages = list(
db.scalars(
select(ChatMessage)
.where(ChatMessage.topic_session_id == topic.id, ChatMessage.user_id == user.id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
)
)
if not messages:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前主题还没有可沉淀的对话内容")
conversation = _messages_text(messages)
prompt = _topic_summary_prompt(topic, conversation)
model_name = _enabled_model_name(db)
try:
raw = ModelClientService.generate_text_or_raise(db, prompt)
parsed = _parse_summary_json(raw)
data = parsed if parsed and "summary" in parsed else _fallback_summary(topic, conversation, raw)
status_value = "success"
error_message = None
except ExternalServiceError as exc:
data = _fallback_summary(topic, conversation, "")
status_value = "fallback"
error_message = str(exc)
if existing is None:
existing = TopicSummary(topic_session_id=topic.id, user_id=user.id)
_apply_summary(existing, data)
existing.model_name = model_name
existing.status = status_value
existing.error_message = error_message
existing.generated_at = _now()
db.add(existing)
db.flush()
return existing
@staticmethod
def update_growth_profile(db: Session, *, user: User, topic_summary: TopicSummary) -> UserGrowthProfile:
profile = db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user.id))
before = growth_profile_dict(profile) if profile is not None else None
if profile is None:
profile = UserGrowthProfile(user_id=user.id, profile_text="")
db.add(profile)
db.flush()
prompt = _growth_profile_prompt(profile, topic_summary)
try:
raw = ModelClientService.generate_text_or_raise(db, prompt)
parsed = _parse_summary_json(raw)
data = parsed if parsed and "profileText" in parsed else _fallback_profile(profile, topic_summary, raw)
except ExternalServiceError:
data = _fallback_profile(profile, topic_summary, "")
_apply_profile(profile, data)
profile.last_topic_summary_id = topic_summary.id
profile.updated_by = "system"
db.add(profile)
db.flush()
after = growth_profile_dict(profile)
db.add(
GrowthProfileRevision(
user_id=user.id,
profile_id=profile.id,
topic_summary_id=topic_summary.id,
before_json=json.dumps(before, ensure_ascii=False, default=str) if before else None,
after_json=json.dumps(after, ensure_ascii=False, default=str),
reason="topic_summary",
)
)
return profile
@staticmethod
def get_growth_profile(db: Session, user_id: int) -> UserGrowthProfile | None:
return db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user_id))
@staticmethod
def recent_topic_summaries(db: Session, user_id: int, *, limit: int = 10) -> list[TopicSummary]:
return list(
db.scalars(
select(TopicSummary)
.where(TopicSummary.user_id == user_id)
.order_by(TopicSummary.generated_at.desc(), TopicSummary.id.desc())
.limit(limit)
)
)
@staticmethod
def prompt_context(db: Session, user: User) -> str | None:
profile = GrowthProfileService.get_growth_profile(db, user.id)
if profile is None or not profile.profile_text.strip():
return None
return (
"[长期成长档案]\n"
"以下是用户跨主题沉淀出的简要成长档案,只能用于理解用户长期模式和延续陪伴,"
"不能替代本轮可靠知识,也不能臆造课程内容。\n"
f"{_limit(profile.profile_text, 1800)}"
)
def topic_dict(topic: TopicSession) -> dict:
return {
"id": topic.id,
"userId": topic.user_id,
"chatSessionId": topic.chat_session_id,
"title": topic.title,
"coreQuestion": topic.core_question,
"status": topic.status,
"messageCount": topic.message_count,
"tokenInput": topic.token_input,
"tokenOutput": topic.token_output,
"quotaDeducted": bool(topic.quota_deducted),
"recommendedHomework": topic.recommended_homework,
"startedAt": topic.started_at,
"endedAt": topic.ended_at,
"createdAt": topic.created_at,
"updatedAt": topic.updated_at,
}
def topic_summary_dict(summary: TopicSummary) -> dict:
return {
"id": summary.id,
"topicSessionId": summary.topic_session_id,
"userId": summary.user_id,
"summary": summary.summary,
"mainEvents": summary.main_events,
"emotions": summary.emotions,
"bodyFeelings": summary.body_feelings,
"beliefs": summary.beliefs,
"recommendedHomework": summary.recommended_homework,
"insights": summary.insights,
"nextObservation": summary.next_observation,
"modelName": summary.model_name,
"status": summary.status,
"errorMessage": summary.error_message,
"generatedAt": summary.generated_at,
}
def growth_profile_dict(profile: UserGrowthProfile | None) -> dict | None:
if profile is None:
return None
return {
"id": profile.id,
"userId": profile.user_id,
"profileText": profile.profile_text,
"recurringTopics": profile.recurring_topics,
"commonEmotions": profile.common_emotions,
"bodyPatterns": profile.body_patterns,
"relationPatterns": profile.relation_patterns,
"homeworkDone": profile.homework_done,
"effectiveHomework": profile.effective_homework,
"recentProgress": profile.recent_progress,
"lastTopicSummaryId": profile.last_topic_summary_id,
"updatedBy": profile.updated_by,
"createdAt": profile.created_at,
"updatedAt": profile.updated_at,
}
def _apply_summary(summary: TopicSummary, data: dict[str, Any]) -> None:
summary.summary = _field(data, "summary", "本主题已沉淀。", 4000)
summary.main_events = _field(data, "mainEvents", "", 2000) or None
summary.emotions = _field(data, "emotions", "", 1000) or None
summary.body_feelings = _field(data, "bodyFeelings", "", 1000) or None
summary.beliefs = _field(data, "beliefs", "", 1000) or None
summary.recommended_homework = _field(data, "recommendedHomework", "", 1500) or None
summary.insights = _field(data, "insights", "", 1500) or None
summary.next_observation = _field(data, "nextObservation", "", 1500) or None
def _apply_profile(profile: UserGrowthProfile, data: dict[str, Any]) -> None:
profile.profile_text = _field(data, "profileText", profile.profile_text or "暂无成长档案。", 5000)
profile.recurring_topics = _field(data, "recurringTopics", "", 2000) or profile.recurring_topics
profile.common_emotions = _field(data, "commonEmotions", "", 1500) or profile.common_emotions
profile.body_patterns = _field(data, "bodyPatterns", "", 1500) or profile.body_patterns
profile.relation_patterns = _field(data, "relationPatterns", "", 1500) or profile.relation_patterns
profile.homework_done = _field(data, "homeworkDone", "", 1500) or profile.homework_done
profile.effective_homework = _field(data, "effectiveHomework", "", 1500) or profile.effective_homework
profile.recent_progress = _field(data, "recentProgress", "", 1500) or profile.recent_progress
def _topic_summary_prompt(topic: TopicSession, conversation: str) -> str:
return (
"你是大本营千问千答的主题沉淀助手。请把一个主题会话总结为结构化 JSON"
"只依据对话内容,不要扩展课程知识,不要评价用户人格。输出字段:"
"summary, mainEvents, emotions, bodyFeelings, beliefs, recommendedHomework, insights, nextObservation。"
"内容要偏“回到当下、回到自身、觉察情绪和感受、如是释放”,不要给很多术层面的建议。\n\n"
f"主题标题:{topic.title}\n核心问题:{topic.core_question}\n\n对话:\n{_limit(conversation, 12000)}"
)
def _growth_profile_prompt(profile: UserGrowthProfile, summary: TopicSummary) -> str:
current = profile.profile_text or "暂无"
return (
"你是大本营千问千答的长期成长档案整理助手。请根据旧档案和最新主题摘要,"
"滚动更新一份结构化 JSON。只保留稳定模式和最近进展避免堆叠流水账。"
"输出字段profileText, recurringTopics, commonEmotions, bodyPatterns, relationPatterns, "
"homeworkDone, effectiveHomework, recentProgress。语气客观、温和不诊断、不贴标签。\n\n"
f"旧档案:\n{_limit(current, 5000)}\n\n最新主题摘要:\n{json.dumps(topic_summary_dict(summary), ensure_ascii=False, default=str)}"
)
def _fallback_summary(topic: TopicSession, conversation: str, raw: str) -> dict[str, str]:
source = raw.strip() if raw.strip() and not raw.strip().startswith("你是大本营") else conversation
user_lines = [line for line in source.splitlines() if line.startswith("用户:")]
assistant_lines = [line for line in source.splitlines() if line.startswith("大本营答疑助手:")]
first_question = user_lines[0].removeprefix("用户:").strip() if user_lines else topic.core_question
last_answer = assistant_lines[-1].removeprefix("大本营答疑助手:").strip() if assistant_lines else ""
return {
"summary": _limit(f"用户围绕“{first_question}”进行了提问,本主题的主要回答和沉淀为:{last_answer}", 1800),
"mainEvents": _limit(first_question, 800),
"emotions": _join_markers(source, ("害怕", "委屈", "愤怒", "抗拒", "焦虑", "担心", "难受")),
"bodyFeelings": _join_markers(source, ("身体", "", "心口", "", "", "", "", "", "")),
"beliefs": _join_markers(source, ("必须", "应该", "不能", "总是", "一定", "不配")),
"recommendedHomework": _join_markers(source, ("静心", "觉察", "功课", "练习", "释放", "内省")),
"insights": _limit(last_answer, 1000),
"nextObservation": "下次继续从当下最明显的身体感受、情绪和自动念头开始观察。",
}
def _fallback_profile(profile: UserGrowthProfile, summary: TopicSummary, raw: str) -> dict[str, str]:
previous = profile.profile_text.strip()
addition = raw.strip() if raw.strip() and not raw.strip().startswith("你是大本营") else summary.summary
text = "\n\n".join(part for part in [previous, f"最近主题:{addition}"] if part)
return {
"profileText": _limit(text or "暂无成长档案。", 4500),
"recurringTopics": summary.main_events or "",
"commonEmotions": summary.emotions or "",
"bodyPatterns": summary.body_feelings or "",
"relationPatterns": summary.beliefs or "",
"homeworkDone": summary.recommended_homework or "",
"effectiveHomework": summary.recommended_homework or "",
"recentProgress": summary.insights or summary.summary,
}
def _parse_summary_json(raw: str) -> dict[str, Any] | None:
text = raw.strip()
if not text:
return None
if text.startswith("```"):
text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip()
text = re.sub(r"```$", "", text).strip()
match = re.search(r"\{[\s\S]*\}", text)
if match:
text = match.group(0)
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
def _messages_text(messages: list[ChatMessage]) -> str:
labels = {"user": "用户", "assistant": "大本营答疑助手"}
return "\n".join(f"{labels.get(message.role, message.role)}{message.content}" for message in messages)
def _field(data: dict[str, Any], key: str, default: str, max_len: int) -> str:
value = data.get(key, default)
if isinstance(value, list):
value = "".join(str(item) for item in value if str(item).strip())
if not isinstance(value, str):
value = str(value)
return _limit(value.strip(), max_len)
def _join_markers(text: str, markers: tuple[str, ...]) -> str:
found = [marker for marker in markers if marker in text]
return "".join(found)
def _limit(text: str, max_len: int) -> str:
return text[:max_len].strip()
def _enabled_model_name(db: Session) -> str | None:
model = ModelClientService._get_enabled_model(db)
return model.model_name if model is not None else None
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)

View File

@@ -85,6 +85,7 @@ class KnowledgeAgentService:
context_trace: list[dict] | None = None,
prompt_override: str | None = None,
response_depth: int | None = None,
growth_context: str | None = None,
) -> RagResult:
started = perf_counter()
catalog = cls.get_knowledge_catalog(
@@ -209,6 +210,7 @@ class KnowledgeAgentService:
summary_up_to_message_id,
prompt_override=prompt_override,
response_depth=response_depth,
growth_context=growth_context,
)
return RagResult(
question=question,

View File

@@ -73,6 +73,16 @@ class ModelClientService:
raise ExternalServiceError("未启用可用于生成历史摘要的模型", provider="model")
return _generate_summary(model, messages_text, previous_summary)
@staticmethod
def generate_text_or_raise(db: Session, prompt: str) -> str:
model = ModelClientService._get_enabled_model(db)
if _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
return prompt.strip()[-1200:]
if model is None:
raise ExternalServiceError("未启用可用于生成文本的模型", provider="model")
rag_result = RagResult(question=prompt, knowledge_scopes=[], chunks=[], prompt=prompt, allow_general_knowledge=True)
return _call_configured_model(model, rag_result, allow_no_hit=True)
@staticmethod
async def summarize_or_raise_async(
db: Session,

View File

@@ -19,6 +19,7 @@ class AsyncRagService:
summary_up_to_message_id: int | None = None,
session_id: int | None = None,
context_trace: list[dict] | None = None,
growth_context: str | None = None,
) -> RagResult:
return await KnowledgeAgentService.build_result(
db,
@@ -29,4 +30,5 @@ class AsyncRagService:
session_id=session_id,
user_id=user.id,
context_trace=context_trace,
growth_context=growth_context,
)

View File

@@ -62,6 +62,7 @@ class RagService:
session_summary: str | None = None,
summary_up_to_message_id: int | None = None,
context_trace: list[dict] | None = None,
growth_context: str | None = None,
) -> RagResult:
scopes = KnowledgeAccessService.get_allowed_knowledge(db, user)
chunks = FeishuKnowledgeService.retrieve(question, scopes, db)
@@ -72,6 +73,7 @@ class RagService:
history,
session_summary,
summary_up_to_message_id,
growth_context=growth_context,
)
prompt = PromptService.render_messages(messages)
return RagResult(
@@ -98,6 +100,7 @@ class PromptService:
history: list[ChatMessage] | None = None,
session_summary: str | None = None,
summary_up_to_message_id: int | None = None,
growth_context: str | None = None,
) -> str:
return cls.render_messages(
cls.build_messages(
@@ -107,6 +110,7 @@ class PromptService:
history,
session_summary,
summary_up_to_message_id,
growth_context=growth_context,
)
)
@@ -121,6 +125,7 @@ class PromptService:
summary_up_to_message_id: int | None = None,
prompt_override: str | None = None,
response_depth: int | None = None,
growth_context: str | None = None,
) -> list[dict[str, str]]:
prompt = prompt_override.strip() if prompt_override and prompt_override.strip() else cls._load_active_prompt(db)
@@ -150,6 +155,8 @@ class PromptService:
)
if visible_summary:
messages.append({"role": "system", "content": f"[历史对话摘要]\n{visible_summary}"})
if growth_context and growth_context.strip():
messages.append({"role": "system", "content": growth_context.strip()})
for message in recent_history:
content = cls._clean_history_content(message.content)

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.models import Base
from app.models.ai_config import SystemConfig
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan
from app.models.growth import GrowthProfileRevision, UserGrowthProfile
from app.models.user import User
from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService
from app.services.rag_service import PromptService
def _db() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def test_finish_topic_generates_summary_and_updates_growth_profile_for_enabled_plan():
with _db() as db:
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
db.add(EntitlementPlan(id=10, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, enable_growth_profile=1, status=1))
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
session = ChatSession(id=1, user_id=1, title="阴影人格", message_count=2, last_message_at=_now(), is_deleted=0)
topic = TopicSession(
id=1,
user_id=1,
chat_session_id=1,
title="阴影人格练习",
core_question="阴影人格练习步骤是什么",
status="active",
message_count=2,
quota_deducted=1,
started_at=_now(),
)
db.add_all([user, session, topic])
db.add_all(
[
ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我做阴影人格会抗拒,身体发紧", created_at=_now()),
ChatMessage(id=2, session_id=1, topic_session_id=1, user_id=1, role="assistant", content="先回到身体感受,观察抗拒,不急着分析。", created_at=_now()),
]
)
db.commit()
EntitlementService.assign_user_plan(db, user=user, plan_id=10, operated_by=1)
db.commit()
result = GrowthProfileService.finish_active_topic(db, user=user, session=session)
assert result["topic"]["status"] == "completed"
assert result["summary"]["summary"]
assert result["profile"] is not None
assert "最近主题" in result["profile"]["profileText"]
assert db.query(UserGrowthProfile).filter_by(user_id=1).count() == 1
assert db.query(GrowthProfileRevision).filter_by(user_id=1).count() == 1
def test_prompt_can_include_growth_profile_context_without_replacing_knowledge_context():
with _db() as db:
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
db.add(user)
db.add(UserGrowthProfile(user_id=1, profile_text="用户最近反复在表达障碍和身体紧绷之间观察。"))
db.commit()
growth_context = GrowthProfileService.prompt_context(db, user)
messages = PromptService.build_messages(
db,
"我现在又表达不出来了怎么办",
[],
growth_context=growth_context,
)
content = "\n".join(item["content"] for item in messages)
assert "[长期成长档案]" in content
assert "表达障碍" in content
assert "[本轮可靠知识上下文]" in content

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