历史记录回显
This commit is contained in:
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 升序排列。"""
|
||||
|
||||
+2
@@ -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))
|
||||
+9
-1
@@ -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)
|
||||
|
||||
+39
@@ -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")
|
||||
Reference in new issue
Block a user