feat: add class share drafts
This commit is contained in:
@@ -1603,7 +1603,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
</el-collapse>
|
</el-collapse>
|
||||||
<el-empty v-else description="该会话暂无主题记录" :image-size="64" />
|
<el-empty v-else description="该会话暂无主题记录" :image-size="64" />
|
||||||
|
|
||||||
<h3 class="detail-title">老师求助卡</h3>
|
<h3 class="detail-title">老师求助卡({{ chatDetail.helpCards?.length || 0 }})</h3>
|
||||||
<el-collapse v-if="chatDetail.helpCards?.length" class="topic-summary-collapse">
|
<el-collapse v-if="chatDetail.helpCards?.length" class="topic-summary-collapse">
|
||||||
<el-collapse-item
|
<el-collapse-item
|
||||||
v-for="card in chatDetail.helpCards"
|
v-for="card in chatDetail.helpCards"
|
||||||
@@ -1622,6 +1622,25 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
</el-collapse>
|
</el-collapse>
|
||||||
<el-empty v-else description="该会话暂无用户生成的求助卡" :image-size="64" />
|
<el-empty v-else description="该会话暂无用户生成的求助卡" :image-size="64" />
|
||||||
|
|
||||||
|
<h3 class="detail-title">班级分享稿({{ chatDetail.shareDrafts?.length || 0 }})</h3>
|
||||||
|
<el-collapse v-if="chatDetail.shareDrafts?.length" class="topic-summary-collapse">
|
||||||
|
<el-collapse-item
|
||||||
|
v-for="draft in chatDetail.shareDrafts"
|
||||||
|
:key="draft.id"
|
||||||
|
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
|
||||||
|
>
|
||||||
|
<section class="topic-summary-card">
|
||||||
|
<div class="topic-summary-meta">
|
||||||
|
<span>生成:{{ draft.createdAt }}</span>
|
||||||
|
<span>复制:{{ draft.copiedAt || '-' }}</span>
|
||||||
|
<span>来源:{{ draft.source }}</span>
|
||||||
|
</div>
|
||||||
|
<pre>{{ draft.content }}</pre>
|
||||||
|
</section>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
<el-empty v-else description="该会话暂无用户生成的班级分享稿" :image-size="64" />
|
||||||
|
|
||||||
<h3 class="detail-title">完整对话</h3>
|
<h3 class="detail-title">完整对话</h3>
|
||||||
<AdminPagination
|
<AdminPagination
|
||||||
class="chat-detail-pagination top"
|
class="chat-detail-pagination top"
|
||||||
|
|||||||
@@ -398,6 +398,7 @@ export interface ChatDetail {
|
|||||||
aiLogs: AiLogRecord[];
|
aiLogs: AiLogRecord[];
|
||||||
topics?: TopicSessionRecord[];
|
topics?: TopicSessionRecord[];
|
||||||
helpCards?: TeacherHelpCardRecord[];
|
helpCards?: TeacherHelpCardRecord[];
|
||||||
|
shareDrafts?: ShareDraftRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherHelpCardRecord {
|
export interface TeacherHelpCardRecord {
|
||||||
@@ -412,6 +413,18 @@ export interface TeacherHelpCardRecord {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareDraftRecord {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
topicSessionId: number;
|
||||||
|
summaryId?: number | null;
|
||||||
|
content: string;
|
||||||
|
source: string;
|
||||||
|
copied: boolean;
|
||||||
|
copiedAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TopicSummaryRecord {
|
export interface TopicSummaryRecord {
|
||||||
id: number;
|
id: number;
|
||||||
topicSessionId: number;
|
topicSessionId: number;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""add share drafts
|
||||||
|
|
||||||
|
Revision ID: 0017_share_drafts
|
||||||
|
Revises: 0016_teacher_help_cards
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0017_share_drafts"
|
||||||
|
down_revision = "0016_teacher_help_cards"
|
||||||
|
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_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")
|
||||||
@@ -16,13 +16,14 @@ from app.core.dependencies import get_current_admin
|
|||||||
from app.core.responses import api_success
|
from app.core.responses import api_success
|
||||||
from app.models.admin import Admin
|
from app.models.admin import Admin
|
||||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
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.logs import AiRequestLog, OperationLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.api.pagination import page_result
|
from app.api.pagination import page_result
|
||||||
from app.services.question_insight_service import QuestionInsightService
|
from app.services.question_insight_service import QuestionInsightService
|
||||||
from app.services.growth_profile_service import topic_dict, topic_summary_dict
|
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.help_card_service import help_card_dict
|
||||||
|
from app.services.share_draft_service import share_draft_dict
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ def chat_detail(
|
|||||||
topics = _topic_rows(db, session_id)
|
topics = _topic_rows(db, session_id)
|
||||||
topic_ids = [item["id"] for item in topics]
|
topic_ids = [item["id"] for item in topics]
|
||||||
help_cards = []
|
help_cards = []
|
||||||
|
share_drafts = []
|
||||||
if topic_ids:
|
if topic_ids:
|
||||||
help_cards = list(
|
help_cards = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
@@ -149,6 +151,13 @@ def chat_detail(
|
|||||||
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
|
.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(
|
return api_success(
|
||||||
{
|
{
|
||||||
"session": _chat_row_dict(session, user),
|
"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],
|
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
|
||||||
"topics": topics,
|
"topics": topics,
|
||||||
"helpCards": [help_card_dict(item) for item in help_cards],
|
"helpCards": [help_card_dict(item) for item in help_cards],
|
||||||
|
"shareDrafts": [share_draft_dict(item) for item in share_drafts],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from app.services.chat_stream_service import ChatStreamService
|
|||||||
from app.services.growth_profile_service import GrowthProfileService
|
from app.services.growth_profile_service import GrowthProfileService
|
||||||
from app.services.help_card_service import HelpCardService, help_card_dict
|
from app.services.help_card_service import HelpCardService, help_card_dict
|
||||||
from app.services.reasoning_policy_service import ReasoningPolicyService
|
from app.services.reasoning_policy_service import ReasoningPolicyService
|
||||||
|
from app.services.share_draft_service import ShareDraftService, share_draft_dict
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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)))
|
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")
|
@router.post("/completions")
|
||||||
def completions(
|
def completions(
|
||||||
payload: ChatCompletionRequest,
|
payload: ChatCompletionRequest,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from app.models.ai_config import ModelConfig, Prompt, SystemConfig
|
|||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||||
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
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 (
|
from app.models.knowledge import (
|
||||||
HumanAttentionHistory,
|
HumanAttentionHistory,
|
||||||
HumanAttentionRecord,
|
HumanAttentionRecord,
|
||||||
@@ -55,6 +55,7 @@ __all__ = [
|
|||||||
"Prompt",
|
"Prompt",
|
||||||
"Role",
|
"Role",
|
||||||
"SystemConfig",
|
"SystemConfig",
|
||||||
|
"ShareDraft",
|
||||||
"TeacherHelpCard",
|
"TeacherHelpCard",
|
||||||
"User",
|
"User",
|
||||||
"UserEntitlement",
|
"UserEntitlement",
|
||||||
|
|||||||
@@ -82,3 +82,21 @@ class TeacherHelpCard(Base):
|
|||||||
copied: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
copied: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
copied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
copied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
created_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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
64
ai_knowledge_base_v2/apps/backend/tests/test_share_drafts.py
Normal file
64
ai_knowledge_base_v2/apps/backend/tests/test_share_drafts.py
Normal file
@@ -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
|
||||||
@@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
|
|||||||
import SessionDrawer from "./components/SessionDrawer.vue";
|
import SessionDrawer from "./components/SessionDrawer.vue";
|
||||||
import SessionQuota from "./components/SessionQuota.vue";
|
import SessionQuota from "./components/SessionQuota.vue";
|
||||||
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
|
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<UserProfile | null>(null);
|
const user = ref<UserProfile | null>(null);
|
||||||
const sessions = ref<ChatSession[]>([]);
|
const sessions = ref<ChatSession[]>([]);
|
||||||
@@ -24,12 +24,16 @@ const sessionOperationPending = ref(false);
|
|||||||
const logoutDialogOpen = ref(false);
|
const logoutDialogOpen = ref(false);
|
||||||
const profileDialogOpen = ref(false);
|
const profileDialogOpen = ref(false);
|
||||||
const helpCardDialogOpen = ref(false);
|
const helpCardDialogOpen = ref(false);
|
||||||
|
const shareDraftDialogOpen = ref(false);
|
||||||
const growthProfile = ref<GrowthProfileResult | null>(null);
|
const growthProfile = ref<GrowthProfileResult | null>(null);
|
||||||
const helpCard = ref<TeacherHelpCard | null>(null);
|
const helpCard = ref<TeacherHelpCard | null>(null);
|
||||||
const helpCardContent = ref("");
|
const helpCardContent = ref("");
|
||||||
|
const shareDraft = ref<ShareDraft | null>(null);
|
||||||
|
const shareDraftContent = ref("");
|
||||||
const profileLoading = ref(false);
|
const profileLoading = ref(false);
|
||||||
const finishingTopic = ref(false);
|
const finishingTopic = ref(false);
|
||||||
const generatingHelpCard = ref(false);
|
const generatingHelpCard = ref(false);
|
||||||
|
const generatingShareDraft = ref(false);
|
||||||
const statusText = ref("连接后端中");
|
const statusText = ref("连接后端中");
|
||||||
const toastText = ref("");
|
const toastText = ref("");
|
||||||
const followingOutput = ref(true);
|
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() {
|
async function openGrowthProfile() {
|
||||||
profileDialogOpen.value = true;
|
profileDialogOpen.value = true;
|
||||||
profileLoading.value = true;
|
profileLoading.value = true;
|
||||||
@@ -394,9 +431,11 @@ async function copyText(text: string) {
|
|||||||
:entitlement="user.entitlement"
|
:entitlement="user.entitlement"
|
||||||
:finishing="finishingTopic"
|
:finishing="finishingTopic"
|
||||||
:generating-help-card="generatingHelpCard"
|
:generating-help-card="generatingHelpCard"
|
||||||
|
:generating-share-draft="generatingShareDraft"
|
||||||
@finish-topic="finishCurrentTopic"
|
@finish-topic="finishCurrentTopic"
|
||||||
@open-profile="openGrowthProfile"
|
@open-profile="openGrowthProfile"
|
||||||
@generate-help-card="generateHelpCard"
|
@generate-help-card="generateHelpCard"
|
||||||
|
@generate-share-draft="generateShareDraft"
|
||||||
/>
|
/>
|
||||||
<MessageList
|
<MessageList
|
||||||
ref="messageList"
|
ref="messageList"
|
||||||
@@ -459,6 +498,19 @@ async function copyText(text: string) {
|
|||||||
</template>
|
</template>
|
||||||
</AppDialog>
|
</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">
|
<AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
|
||||||
<p class="confirm-copy">确定退出当前账号吗?</p>
|
<p class="confirm-copy">确定退出当前账号吗?</p>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ const props = defineProps<{
|
|||||||
entitlement?: UserEntitlementSummary | null;
|
entitlement?: UserEntitlementSummary | null;
|
||||||
finishing?: boolean;
|
finishing?: boolean;
|
||||||
generatingHelpCard?: boolean;
|
generatingHelpCard?: boolean;
|
||||||
|
generatingShareDraft?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
finishTopic: [];
|
finishTopic: [];
|
||||||
openProfile: [];
|
openProfile: [];
|
||||||
generateHelpCard: [];
|
generateHelpCard: [];
|
||||||
|
generateShareDraft: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const hasEntitlement = computed(() => Boolean(props.entitlement));
|
const hasEntitlement = computed(() => Boolean(props.entitlement));
|
||||||
@@ -54,6 +56,15 @@ const limitText = computed(() => displayLimit.value === null ? "不限" : String
|
|||||||
>
|
>
|
||||||
{{ generatingHelpCard ? '生成中' : '求助卡' }}
|
{{ generatingHelpCard ? '生成中' : '求助卡' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="entitlement?.allowShareDraft"
|
||||||
|
type="button"
|
||||||
|
class="quota-link"
|
||||||
|
:disabled="generatingShareDraft"
|
||||||
|
@click="$emit('generateShareDraft')"
|
||||||
|
>
|
||||||
|
{{ generatingShareDraft ? '生成中' : '分享稿' }}
|
||||||
|
</button>
|
||||||
<strong>{{ displayUsed }}/{{ limitText }}</strong>
|
<strong>{{ displayUsed }}/{{ limitText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
FinishTopicResult,
|
FinishTopicResult,
|
||||||
GrowthProfileResult,
|
GrowthProfileResult,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
|
ShareDraft,
|
||||||
TeacherHelpCard,
|
TeacherHelpCard,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
@@ -90,6 +91,9 @@ export const api = {
|
|||||||
generateHelpCard: (sessionId: number) => request<TeacherHelpCard>(`/chat/session/${sessionId}/help-card`, { method: "POST", body: JSON.stringify({}) }),
|
generateHelpCard: (sessionId: number) => request<TeacherHelpCard>(`/chat/session/${sessionId}/help-card`, { method: "POST", body: JSON.stringify({}) }),
|
||||||
markHelpCardCopied: (cardId: number) => request<TeacherHelpCard>(`/chat/help-card/${cardId}/copied`, { method: "POST", body: JSON.stringify({}) }),
|
markHelpCardCopied: (cardId: number) => request<TeacherHelpCard>(`/chat/help-card/${cardId}/copied`, { method: "POST", body: JSON.stringify({}) }),
|
||||||
helpCards: (limit = 20) => request<TeacherHelpCard[]>(`/chat/help-card/list?limit=${limit}`),
|
helpCards: (limit = 20) => request<TeacherHelpCard[]>(`/chat/help-card/list?limit=${limit}`),
|
||||||
|
generateShareDraft: (sessionId: number) => request<ShareDraft>(`/chat/session/${sessionId}/share-draft`, { method: "POST", body: JSON.stringify({}) }),
|
||||||
|
markShareDraftCopied: (draftId: number) => request<ShareDraft>(`/chat/share-draft/${draftId}/copied`, { method: "POST", body: JSON.stringify({}) }),
|
||||||
|
shareDrafts: (limit = 20) => request<ShareDraft[]>(`/chat/share-draft/list?limit=${limit}`),
|
||||||
growthProfile: () => request<GrowthProfileResult>("/user/growth-profile"),
|
growthProfile: () => request<GrowthProfileResult>("/user/growth-profile"),
|
||||||
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1204,9 +1204,10 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin: 12px 16px 0;
|
margin: 12px 16px 0;
|
||||||
padding: 0 14px;
|
padding: 8px 14px;
|
||||||
border: 1px solid rgba(217, 230, 225, 0.75);
|
border: 1px solid rgba(217, 230, 225, 0.75);
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
background: var(--chat-brand-soft);
|
background: var(--chat-brand-soft);
|
||||||
@@ -1234,6 +1235,8 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,18 @@ export interface TeacherHelpCard {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareDraft {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
topicSessionId: number;
|
||||||
|
summaryId?: number | null;
|
||||||
|
content: string;
|
||||||
|
source: string;
|
||||||
|
copied: boolean;
|
||||||
|
copiedAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
token: string;
|
token: string;
|
||||||
expiredAt: string;
|
expiredAt: string;
|
||||||
|
|||||||
@@ -485,6 +485,10 @@ Agent 已经可以:
|
|||||||
- 分享稿不会自动发送到任何群;
|
- 分享稿不会自动发送到任何群;
|
||||||
- 后台可统计生成次数。
|
- 后台可统计生成次数。
|
||||||
|
|
||||||
|
#### 开发进度
|
||||||
|
|
||||||
|
- 2026-07-31:已新增 `sys_share_draft`,用户端支持从当前会话生成“班级分享稿”、弹窗编辑并复制;复制后仅记录已复制时间。后台聊天详情可查看生成过的分享稿内容和复制状态,不自动发送到任何群。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 07. 知识库类型规则继续落地
|
### 07. 知识库类型规则继续落地
|
||||||
|
|||||||
Reference in New Issue
Block a user