From 0af8ca5f78a1eba4854df136d1170ffa72732fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 17 May 2026 22:55:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E5=9B=9E?= =?UTF-8?q?=E6=98=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../open-canvas/api/ai-writing-sessions.ts | 22 +++++ .../ai-writing/AIWritingHistoryView.tsx | 80 +++++++++++-------- .../src/open-canvas/hooks/useAIWriting.ts | 14 ++++ .../backend/app/gateway/routers/ai_writing.py | 21 +++++ .../persistence/ai_writing_sessions/model.py | 2 + .../persistence/ai_writing_sessions/sql.py | 10 ++- .../20260517_03_ai_writing_transcript.py | 39 +++++++++ 7 files changed, 155 insertions(+), 33 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260517_03_ai_writing_transcript.py diff --git a/frontend-web/src/open-canvas/api/ai-writing-sessions.ts b/frontend-web/src/open-canvas/api/ai-writing-sessions.ts index ac535d52..eae5cad8 100644 --- a/frontend-web/src/open-canvas/api/ai-writing-sessions.ts +++ b/frontend-web/src/open-canvas/api/ai-writing-sessions.ts @@ -1,6 +1,8 @@ import { getBackendBaseURL } from '@/core/config'; import { apiFetch } from '@/core/api'; +import type { ProgressEvent, CompletedIntervention } from '../types/ai-writing'; + export interface AIWritingSessionSummary { id: string; user_id: string | null; @@ -11,6 +13,7 @@ export interface AIWritingSessionSummary { draft_markdown: string | null; completed_interventions: CompletedInterventionRecord[] | null; review_result: Record | null; + transcript: AIWritingTranscript | null; created_at: string; updated_at: string; } @@ -21,6 +24,12 @@ export interface CompletedInterventionRecord { timestamp: string; } +/** 完整对话时间线快照:与实时对话用同一份数据结构,历史回显时直接喂给 AIWritingTimeline。 */ +export interface AIWritingTranscript { + progressEvents: ProgressEvent[]; + completedInterventions: CompletedIntervention[]; +} + const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`; export async function listAIWritingSessions(): Promise { @@ -49,3 +58,16 @@ export async function deleteAIWritingSession(id: string): Promise { const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' }); if (!resp.ok) throw new Error(`删除失败: ${resp.status}`); } + +/** 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。 */ +export async function saveAIWritingTranscript( + id: string, + transcript: AIWritingTranscript, +): Promise { + const resp = await apiFetch(`${BASE()}/${id}/transcript`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transcript }), + }); + if (!resp.ok) throw new Error(`保存对话时间线失败: ${resp.status}`); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx index e1e46d1c..04a70a25 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx @@ -1,6 +1,7 @@ import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react'; import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions'; +import { AIWritingTimeline } from './AIWritingTimeline'; const PAUSE_LABELS: Record = { material_confirm: '素材确认', @@ -63,6 +64,9 @@ interface AIWritingHistoryViewProps { export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) { const interventions = session.completed_interventions ?? []; + const transcript = session.transcript; + // 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致 + const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0; const createdAt = session.created_at ? new Date(session.created_at).toLocaleString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric', @@ -71,43 +75,55 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) { : ''; return ( -
+
{/* 元信息 */} -
-

{session.draft_title ?? session.title}

- {session.user_intent && ( -

- {session.user_intent} -

- )} - {createdAt && ( -

{createdAt}

- )} +
+
+

{session.draft_title ?? session.title}

+ {session.user_intent && ( +

+ {session.user_intent} +

+ )} + {createdAt && ( +

{createdAt}

+ )} +
- {/* 阶段时间线 */} - {interventions.length > 0 ? ( -
-

写作阶段

-
- {interventions.map((rec, i) => ( - - ))} -
-
+ {hasTranscript ? ( + /* 完整对话时间线回显 */ + ) : ( -
无阶段记录
- )} + /* 旧会话无时间线快照时,回退到精简阶段列表 */ +
+ {interventions.length > 0 ? ( +
+

写作阶段

+
+ {interventions.map((rec, i) => ( + + ))} +
+
+ ) : ( +
无阶段记录
+ )} - {/* 完成状态 */} - {session.status === 'done' && ( -
- - 写作完成 + {session.status === 'done' && ( +
+ + 写作完成 +
+ )}
)}
diff --git a/frontend-web/src/open-canvas/hooks/useAIWriting.ts b/frontend-web/src/open-canvas/hooks/useAIWriting.ts index af5fe2eb..a4bf423c 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWriting.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWriting.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing'; +import { saveAIWritingTranscript } from '../api/ai-writing-sessions'; import { countWords } from '../utils/wordCount'; import type { AIWritingRequest, @@ -395,5 +396,18 @@ export function useAIWriting() { useEffect(() => () => { abortRef.current?.abort(); }, []); + // 在暂停点 / 完成 / 出错时,把完整对话时间线持久化到后端,供历史回显出与实时一致的内容 + useEffect(() => { + const s = state.status; + const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_'); + if (!state.sessionId || !savable || state.progressEvents.length === 0) return; + saveAIWritingTranscript(state.sessionId, { + progressEvents: state.progressEvents, + completedInterventions: state.completedInterventions, + }).catch(() => {}); + // 仅在状态进入可保存阶段时触发;events/interventions 取闭包最新值,无需进 deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.status, state.sessionId]); + return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }; } diff --git a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py index 9a19fb63..171f1731 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py @@ -63,6 +63,11 @@ class RenameSessionRequest(BaseModel): title: str +class SaveTranscriptRequest(BaseModel): + # 前端组装的完整对话时间线(progressEvents + completedInterventions) + transcript: dict + + class ArticleTypeRequest(BaseModel): key: str label: str @@ -103,6 +108,7 @@ class SessionResponse(BaseModel): draft_markdown: Optional[str] = None completed_interventions: Optional[list] = None review_result: Optional[dict] = None + transcript: Optional[dict] = None created_at: str updated_at: str @@ -285,6 +291,21 @@ async def delete_session(session_id: str, request: Request): return {"success": True} +@router.put("/sessions/{session_id}/transcript") +async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: Request): + """保存会话的完整对话时间线,供历史会话回显出与实时对话一致的富时间线。""" + from app.gateway.deps import get_ai_writing_session_store, get_current_user + + repo = get_ai_writing_session_store(request) + if repo is None: + return {"success": False} + user_id = await get_current_user(request) + updated = await repo.update(session_id, user_id=user_id, transcript=body.transcript) + if updated is None: + raise HTTPException(status_code=404, detail="Session not found") + return {"success": True} + + @router.get("/article-types", response_model=list[ArticleTypeResponse]) async def list_article_types(request: Request): """列出所有文章类型,按 sort_order 升序排列。""" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/model.py index 2ba75666..a4d33a0e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/model.py @@ -22,5 +22,7 @@ class AIWritingSessionRow(Base): draft_markdown: Mapped[str | None] = mapped_column(Text, nullable=True) completed_interventions: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array review_result: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object + # 完整对话时间线(前端组装的 progressEvents + completedInterventions),用于历史回显 + transcript: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/sql.py index 4ecce86b..48fd54ea 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/sql.py @@ -24,7 +24,7 @@ class AIWritingSessionRepository: for k in ("created_at", "updated_at"): if isinstance(d.get(k), datetime): d[k] = d[k].isoformat() - for k in ("completed_interventions", "review_result"): + for k in ("completed_interventions", "review_result", "transcript"): val = d.get(k) if isinstance(val, str): try: @@ -53,6 +53,7 @@ class AIWritingSessionRepository: draft_markdown=None, completed_interventions=None, review_result=None, + transcript=None, created_at=now, updated_at=now, ) @@ -73,6 +74,7 @@ class AIWritingSessionRepository: draft_markdown=_UNSET, completed_interventions=_UNSET, review_result=_UNSET, + transcript=_UNSET, ) -> dict | None: resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.update") async with self._sf() as session: @@ -101,6 +103,12 @@ class AIWritingSessionRepository: if review_result is not None else None ) + if transcript is not _UNSET: + row.transcript = ( + json.dumps(transcript, ensure_ascii=False) + if transcript is not None + else None + ) row.updated_at = datetime.now(UTC) await session.commit() await session.refresh(row) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260517_03_ai_writing_transcript.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260517_03_ai_writing_transcript.py new file mode 100644 index 00000000..62318040 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260517_03_ai_writing_transcript.py @@ -0,0 +1,39 @@ +"""Add transcript column to ai_writing_sessions. + +存储前端组装的完整对话时间线(progressEvents + completedInterventions), +供历史会话回显出与实时对话一致的富时间线。 + +Revision ID: 20260517_03 +Revises: 20260517_02 +Create Date: 2026-05-17 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260517_03" +down_revision = "20260517_02" +branch_labels = None +depends_on = None + + +def _has_column(table_name: str, column_name: str) -> bool: + inspector = inspect(op.get_bind()) + if table_name not in inspector.get_table_names(): + return False + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if _has_column("ai_writing_sessions", "transcript"): + return + op.add_column("ai_writing_sessions", sa.Column("transcript", sa.Text, nullable=True)) + + +def downgrade() -> None: + if _has_column("ai_writing_sessions", "transcript"): + op.drop_column("ai_writing_sessions", "transcript")