diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index b1eb82e..327322e 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -1603,7 +1603,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") { -

老师求助卡

+

老师求助卡({{ chatDetail.helpCards?.length || 0 }})

+

班级分享稿({{ chatDetail.shareDrafts?.length || 0 }})

+ + +
+
+ 生成:{{ draft.createdAt }} + 复制:{{ draft.copiedAt || '-' }} + 来源:{{ draft.source }} +
+
{{ draft.content }}
+
+
+
+ +

完整对话

None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "sys_share_draft" in tables: + return + op.create_table( + "sys_share_draft", + 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("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False), + sa.Column("summary_id", sa.BigInteger(), nullable=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("source", sa.String(30), nullable=False, server_default="topic_summary"), + sa.Column("copied", sa.Integer(), nullable=False, server_default="0"), + sa.Column("copied_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_sys_share_draft_user_id", "sys_share_draft", ["user_id"]) + op.create_index("ix_sys_share_draft_summary_id", "sys_share_draft", ["summary_id"]) + op.create_index("ix_sys_share_draft_user_created", "sys_share_draft", ["user_id", "created_at"]) + op.create_index("ix_sys_share_draft_topic_created", "sys_share_draft", ["topic_session_id", "created_at"]) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "sys_share_draft" not in tables: + return + op.drop_index("ix_sys_share_draft_topic_created", table_name="sys_share_draft") + op.drop_index("ix_sys_share_draft_user_created", table_name="sys_share_draft") + op.drop_index("ix_sys_share_draft_summary_id", table_name="sys_share_draft") + op.drop_index("ix_sys_share_draft_user_id", table_name="sys_share_draft") + op.drop_table("sys_share_draft") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py index a9b3630..fdae8f0 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py @@ -16,13 +16,14 @@ 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, TopicSession -from app.models.growth import TeacherHelpCard, TopicSummary +from app.models.growth import ShareDraft, TeacherHelpCard, 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 from app.services.help_card_service import help_card_dict +from app.services.share_draft_service import share_draft_dict router = APIRouter() @@ -141,6 +142,7 @@ def chat_detail( topics = _topic_rows(db, session_id) topic_ids = [item["id"] for item in topics] help_cards = [] + share_drafts = [] if topic_ids: help_cards = list( db.scalars( @@ -149,6 +151,13 @@ def chat_detail( .order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc()) ) ) + share_drafts = list( + db.scalars( + select(ShareDraft) + .where(ShareDraft.topic_session_id.in_(topic_ids)) + .order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc()) + ) + ) return api_success( { "session": _chat_row_dict(session, user), @@ -162,6 +171,7 @@ def chat_detail( "aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs], "topics": topics, "helpCards": [help_card_dict(item) for item in help_cards], + "shareDrafts": [share_draft_dict(item) for item in share_drafts], } ) diff --git a/ai_knowledge_base_v2/apps/backend/app/api/chat.py b/ai_knowledge_base_v2/apps/backend/app/api/chat.py index 2293c0c..5109087 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/chat.py @@ -33,6 +33,7 @@ from app.services.chat_stream_service import ChatStreamService from app.services.growth_profile_service import GrowthProfileService from app.services.help_card_service import HelpCardService, help_card_dict from app.services.reasoning_policy_service import ReasoningPolicyService +from app.services.share_draft_service import ShareDraftService, share_draft_dict router = APIRouter() logger = logging.getLogger(__name__) @@ -126,6 +127,35 @@ def mark_help_card_copied( return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current_user, card_id=card_id))) +@router.post("/session/{session_id}/share-draft") +def generate_share_draft( + 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) + draft = ShareDraftService.generate_for_session(db, user=current_user, session=session) + return api_success(share_draft_dict(draft)) + + +@router.get("/share-draft/list") +def list_share_drafts( + limit: int = Query(default=20, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + return api_success([share_draft_dict(draft) for draft in ShareDraftService.list_user_drafts(db, user=current_user, limit=limit)]) + + +@router.post("/share-draft/{draft_id}/copied") +def mark_share_draft_copied( + draft_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + return api_success(share_draft_dict(ShareDraftService.mark_copied(db, user=current_user, draft_id=draft_id))) + + @router.post("/completions") def completions( payload: ChatCompletionRequest, diff --git a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py index f5335ca..1ab3a7a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py @@ -3,7 +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, TeacherHelpCard, TopicSummary, UserGrowthProfile +from app.models.growth import GrowthProfileRevision, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile from app.models.knowledge import ( HumanAttentionHistory, HumanAttentionRecord, @@ -55,6 +55,7 @@ __all__ = [ "Prompt", "Role", "SystemConfig", + "ShareDraft", "TeacherHelpCard", "User", "UserEntitlement", diff --git a/ai_knowledge_base_v2/apps/backend/app/models/growth.py b/ai_knowledge_base_v2/apps/backend/app/models/growth.py index 3149c80..e3ed57e 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/growth.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/growth.py @@ -82,3 +82,21 @@ class TeacherHelpCard(Base): copied: Mapped[int] = mapped_column(Integer, default=0, nullable=False) copied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + + +class ShareDraft(Base): + __tablename__ = "sys_share_draft" + __table_args__ = ( + Index("ix_sys_share_draft_user_created", "user_id", "created_at"), + Index("ix_sys_share_draft_topic_created", "topic_session_id", "created_at"), + ) + + 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) + topic_session_id: Mapped[int] = mapped_column(ForeignKey("sys_topic_session.id"), index=True, nullable=False) + summary_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + source: Mapped[str] = mapped_column(String(30), default="topic_summary", nullable=False) + copied: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + copied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/share_draft_service.py b/ai_knowledge_base_v2/apps/backend/app/services/share_draft_service.py new file mode 100644 index 0000000..9df49a9 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/share_draft_service.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.chat import ChatSession, TopicSession +from app.models.growth import ShareDraft, TopicSummary +from app.models.user import User +from app.services.entitlement_service import EntitlementService +from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict + + +class ShareDraftService: + @staticmethod + def generate_for_session(db: Session, *, user: User, session: ChatSession) -> ShareDraft: + entitlement = EntitlementService.active_entitlement(db, user) + if not entitlement.allow_share_draft: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前权益暂不支持生成班级分享稿") + + topic = _latest_topic(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) + draft = ShareDraft( + user_id=user.id, + topic_session_id=topic.id, + summary_id=summary.id, + content=_render_share_draft(topic=topic, summary=summary), + source="topic_summary", + ) + topic.share_draft_generated = 1 + db.add_all([topic, draft]) + db.commit() + db.refresh(draft) + return draft + + @staticmethod + def list_user_drafts(db: Session, *, user: User, limit: int = 20) -> list[ShareDraft]: + return list( + db.scalars( + select(ShareDraft) + .where(ShareDraft.user_id == user.id) + .order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc()) + .limit(limit) + ) + ) + + @staticmethod + def mark_copied(db: Session, *, user: User, draft_id: int) -> ShareDraft: + draft = db.scalar(select(ShareDraft).where(ShareDraft.id == draft_id, ShareDraft.user_id == user.id)) + if draft is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在") + draft.copied = 1 + draft.copied_at = _now() + db.add(draft) + db.commit() + db.refresh(draft) + return draft + + +def share_draft_dict(draft: ShareDraft) -> dict: + return { + "id": draft.id, + "userId": draft.user_id, + "topicSessionId": draft.topic_session_id, + "summaryId": draft.summary_id, + "content": draft.content, + "source": draft.source, + "copied": bool(draft.copied), + "copiedAt": draft.copied_at, + "createdAt": draft.created_at, + } + + +def _latest_topic(db: Session, *, user: User, session: ChatSession) -> TopicSession | None: + return db.scalar( + select(TopicSession) + .where(TopicSession.user_id == user.id, TopicSession.chat_session_id == session.id) + .order_by(TopicSession.updated_at.desc(), TopicSession.id.desc()) + .limit(1) + ) + + +def _render_share_draft(*, topic: TopicSession, summary: TopicSummary) -> str: + data = topic_summary_dict(summary) + return ( + "【实修分享稿草稿】\n" + "说明:这是根据我本次对话整理出的分享草稿,系统不会自动发送到任何群," + "我会按真实情况删改后再决定是否发到班级群。\n\n" + "大家好,我想分享一下这次实修里看到的一点东西。\n\n" + "1. 我这次观察到的议题\n" + f"{topic.core_question or topic.title}\n\n" + "2. 我看见了什么\n" + f"{data.get('summary') or '(请用自己的话补充真实看见)'}\n\n" + "3. 我感受到的情绪和身体反应\n" + f"情绪:{data.get('emotions') or '(请补充)'}\n" + f"身体:{data.get('bodyFeelings') or '(请补充)'}\n\n" + "4. 我做了什么功课 / 准备继续做什么\n" + f"{data.get('recommendedHomework') or '(请补充)'}\n\n" + "5. 当下的一点变化\n" + f"{data.get('insights') or '(请补充真实变化,不需要夸大)'}\n\n" + "6. 我还在继续观察的方向\n" + f"{data.get('nextObservation') or '(请补充)'}\n\n" + "备注:这只是我的阶段性观察,不代表已经彻底解决,也不是建议别人照搬。" + ) + + +def _now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_share_drafts.py b/ai_knowledge_base_v2/apps/backend/tests/test_share_drafts.py new file mode 100644 index 0000000..4b63993 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_share_drafts.py @@ -0,0 +1,64 @@ +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.chat import ChatMessage, ChatSession, TopicSession +from app.models.entitlement import EntitlementPlan +from app.models.growth import ShareDraft +from app.models.user import User +from app.services.share_draft_service import ShareDraftService + + +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_generate_share_draft_from_topic_summary_and_mark_copied(): + with _db() as db: + user = User(id=1, phone="13800000001", name="测试学员", daily_chat_limit=100, daily_chat_used=0) + plan = EntitlementPlan(id=10, name="基础版", plan_type="basic", allow_share_draft=1, status=1) + 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, plan, 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() + + draft = ShareDraftService.generate_for_session(db, user=user, session=session) + + assert "实修分享稿草稿" in draft.content + assert "系统不会自动发送到任何群" in draft.content + assert "不代表已经彻底解决" in draft.content + assert "我看见自己不敢表达" in draft.content + assert db.get(TopicSession, 1).share_draft_generated == 1 + assert db.query(ShareDraft).count() == 1 + + copied = ShareDraftService.mark_copied(db, user=user, draft_id=draft.id) + + assert copied.copied == 1 + assert copied.copied_at is not None diff --git a/ai_knowledge_base_v2/apps/user-client/src/App.vue b/ai_knowledge_base_v2/apps/user-client/src/App.vue index eda53ff..0fd4a4e 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/App.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/App.vue @@ -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, GrowthProfileResult, TeacherHelpCard, UserProfile } from "./types/api"; +import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api"; const user = ref(null); const sessions = ref([]); @@ -24,12 +24,16 @@ const sessionOperationPending = ref(false); const logoutDialogOpen = ref(false); const profileDialogOpen = ref(false); const helpCardDialogOpen = ref(false); +const shareDraftDialogOpen = ref(false); const growthProfile = ref(null); const helpCard = ref(null); const helpCardContent = ref(""); +const shareDraft = ref(null); +const shareDraftContent = ref(""); const profileLoading = ref(false); const finishingTopic = ref(false); const generatingHelpCard = ref(false); +const generatingShareDraft = ref(false); const statusText = ref("连接后端中"); const toastText = ref(""); const followingOutput = ref(true); @@ -251,6 +255,39 @@ async function copyHelpCard() { } } +async function generateShareDraft() { + if (!activeSessionId.value || sending.value || generatingShareDraft.value) return; + generatingShareDraft.value = true; + try { + const result = await api.generateShareDraft(activeSessionId.value); + shareDraft.value = result; + shareDraftContent.value = result.content; + shareDraftDialogOpen.value = true; + showToast("分享稿已生成,可编辑后复制"); + await refreshProfile(); + } catch (error) { + handleError(error, "分享稿生成失败"); + } finally { + generatingShareDraft.value = false; + } +} + +async function copyShareDraft() { + if (!shareDraftContent.value.trim()) { + showToast("分享稿内容为空"); + return; + } + try { + await copyText(shareDraftContent.value); + if (shareDraft.value) { + shareDraft.value = await api.markShareDraftCopied(shareDraft.value.id); + } + showToast("已复制,可粘贴到班级群"); + } catch (error) { + handleError(error, "复制失败,请手动选择文本复制"); + } +} + async function openGrowthProfile() { profileDialogOpen.value = true; profileLoading.value = true; @@ -394,9 +431,11 @@ async function copyText(text: string) { :entitlement="user.entitlement" :finishing="finishingTopic" :generating-help-card="generatingHelpCard" + :generating-share-draft="generatingShareDraft" @finish-topic="finishCurrentTopic" @open-profile="openGrowthProfile" @generate-help-card="generateHelpCard" + @generate-share-draft="generateShareDraft" /> + +
+

这只是分享草稿,系统不会自动发送到任何群。请删掉不想公开的隐私内容,并按自己的真实状态修改后再复制。

+