历史记录回显

This commit is contained in:
董宏凯 committed 2026-05-17 22:55:01 +08:00
1 parent 52ba178c4e
commit 0af8ca5f78
7 files changed
+155 -33

No files matched your search

@@ -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<string, unknown> | 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<AIWritingSessionSummary[]> {
@@ -49,3 +58,16 @@ export async function deleteAIWritingSession(id: string): Promise<void> {
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<void> {
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}`);
}
@@ -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<string, string> = {
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 (
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
<div className="flex-1 overflow-y-auto">
{/* 元信息 */}
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
{session.user_intent && (
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{session.user_intent}
</p>
)}
{createdAt && (
<p className="text-[10px] text-muted-foreground">{createdAt}</p>
)}
<div className="px-4 pt-3">
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
{session.user_intent && (
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{session.user_intent}
</p>
)}
{createdAt && (
<p className="text-[10px] text-muted-foreground">{createdAt}</p>
)}
</div>
</div>
{/* 阶段时间线 */}
{interventions.length > 0 ? (
<div>
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
<div>
{interventions.map((rec, i) => (
<InterventionTimelineItem
key={i}
record={rec}
isLast={i === interventions.length - 1}
/>
))}
</div>
</div>
{hasTranscript ? (
/* 完整对话时间线回显 */
<AIWritingTimeline
events={transcript!.progressEvents}
completedInterventions={transcript!.completedInterventions ?? []}
currentPause={null}
/>
) : (
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
)}
/* 旧会话无时间线快照时,回退到精简阶段列表 */
<div className="px-4 py-3 space-y-4">
{interventions.length > 0 ? (
<div>
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
<div>
{interventions.map((rec, i) => (
<InterventionTimelineItem
key={i}
record={rec}
isLast={i === interventions.length - 1}
/>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
)}
{/* 完成状态 */}
{session.status === 'done' && (
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<Star className="w-3.5 h-3.5" />
<span>写作完成</span>
{session.status === 'done' && (
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<Star className="w-3.5 h-3.5" />
<span>写作完成</span>
</div>
)}
</div>
)}
</div>
@@ -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 };
}
@@ -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 升序排列。"""
@@ -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))
@@ -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)
@@ -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")