ai写作
This commit is contained in:
1 parent
6cab62d140
commit
918e65d8d3
16 files changed
+1143
-26
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
export interface AIWritingSessionSummary {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
title: string;
|
||||
user_intent: string | null;
|
||||
status: string;
|
||||
draft_title: string | null;
|
||||
draft_markdown: string | null;
|
||||
completed_interventions: CompletedInterventionRecord[] | null;
|
||||
review_result: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CompletedInterventionRecord {
|
||||
pausePoint: string;
|
||||
action: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
|
||||
|
||||
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function getAIWritingSession(id: string): Promise<AIWritingSessionSummary> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`);
|
||||
if (!resp.ok) throw new Error(`获取会话失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function renameAIWritingSession(id: string, title: string): Promise<AIWritingSessionSummary> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`重命名失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function deleteAIWritingSession(id: string): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { FileText, PenLine } from 'lucide-react';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
|
||||
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
|
||||
import type { CanvasArtifactText } from '../../types/artifact';
|
||||
|
||||
function DraftWaitingPlaceholder() {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-5 select-none">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center">
|
||||
<FileText className="w-8 h-8 text-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<PenLine className="w-3.5 h-3.5 text-primary/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1.5 max-w-[200px]">
|
||||
<p className="text-sm font-medium text-foreground/60">草稿将在此处显示</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
完成写作流程后,AI 生成的内容将实时出现在这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIWritingDraftPanel() {
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10 border-l">
|
||||
<DraftWaitingPlaceholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden border-l">
|
||||
{/* 标题栏 */}
|
||||
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{content?.title ?? '草稿预览'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{content && !isCodeArtifact(content) ? (
|
||||
<TextArtifactRenderer content={content as CanvasArtifactText} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { History, Trash2, Check, X, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
in_progress: { label: '进行中', color: 'text-blue-500' },
|
||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||
done: { label: '完成', color: 'text-green-500' },
|
||||
error: { label: '错误', color: 'text-destructive' },
|
||||
};
|
||||
|
||||
function formatRelative(isoStr: string): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs} 小时前`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days} 天前`;
|
||||
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
interface SessionItemProps {
|
||||
session: AIWritingSessionSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onRename: (title: string) => void;
|
||||
}
|
||||
|
||||
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(session.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { label, color } = STATUS_LABELS[session.status] ?? { label: session.status, color: 'text-muted-foreground' };
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus();
|
||||
}, [editing]);
|
||||
|
||||
function commitRename() {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== session.title) onRename(trimmed);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
else if (e.key === 'Escape') { setEditValue(session.title); setEditing(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
|
||||
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
|
||||
)}
|
||||
onClick={!editing ? onSelect : undefined}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 text-xs bg-background border rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button className="shrink-0 text-green-500 hover:text-green-600" onClick={commitRename}>
|
||||
<Check className="w-3 h-3" />
|
||||
</button>
|
||||
<button className="shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setEditValue(session.title); setEditing(false); }}>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="text-xs font-medium truncate leading-snug"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true); }}
|
||||
title="双击重命名"
|
||||
>
|
||||
{session.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮,hover 时显示 */}
|
||||
{!editing && (
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 shrink-0 p-0.5 text-muted-foreground hover:text-destructive transition-opacity"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AIWritingHistoryDropdownProps {
|
||||
sessions: AIWritingSessionSummary[];
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
onSelect: (session: AIWritingSessionSummary) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function AIWritingHistoryDropdown({
|
||||
sessions,
|
||||
selectedId,
|
||||
loading,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onRefresh,
|
||||
}: AIWritingHistoryDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
function handleOpen() {
|
||||
setOpen((v) => {
|
||||
if (!v) onRefresh();
|
||||
return !v;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
onClick={handleOpen}
|
||||
title="查看历史记录"
|
||||
>
|
||||
<History className="size-3" />
|
||||
历史
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border bg-popover shadow-lg overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<span className="text-xs font-semibold">写作历史</span>
|
||||
<button
|
||||
className={cn('text-muted-foreground hover:text-foreground transition-transform', loading && 'animate-spin')}
|
||||
onClick={onRefresh}
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-muted-foreground">
|
||||
{loading ? '加载中…' : '暂无历史记录'}
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SessionItem
|
||||
key={s.id}
|
||||
session={s}
|
||||
isSelected={s.id === selectedId}
|
||||
onSelect={() => { onSelect(s); setOpen(false); }}
|
||||
onDelete={() => onDelete(s.id)}
|
||||
onRename={(title) => onRename(s.id, title)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
|
||||
|
||||
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
|
||||
|
||||
const PAUSE_LABELS: Record<string, string> = {
|
||||
material_confirm: '素材确认',
|
||||
outline_confirm: '大纲确认',
|
||||
draft_confirm: '草稿确认',
|
||||
review_confirm: '编辑意见处理',
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
approve: '已批准',
|
||||
re_search: '重新搜索',
|
||||
re_outline: '重新规划',
|
||||
finalize: '直接定稿',
|
||||
to_editor: '提交审核',
|
||||
user_revise: '手动修改',
|
||||
accept_review: '接受意见',
|
||||
force_finalize: '强制定稿',
|
||||
};
|
||||
|
||||
const PAUSE_ICONS: Record<string, React.ElementType> = {
|
||||
material_confirm: Search,
|
||||
outline_confirm: PenLine,
|
||||
draft_confirm: FileText,
|
||||
review_confirm: ClipboardEdit,
|
||||
};
|
||||
|
||||
function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) {
|
||||
const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2;
|
||||
const ts = record.timestamp
|
||||
? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
{!isLast && <div className="w-px flex-1 bg-border/60 mt-1" />}
|
||||
</div>
|
||||
<div className="pb-4 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">
|
||||
{PAUSE_LABELS[record.pausePoint] ?? record.pausePoint}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{ACTION_LABELS[record.action] ?? record.action}
|
||||
</span>
|
||||
</div>
|
||||
{ts && <p className="text-[10px] text-muted-foreground mt-0.5">{ts}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AIWritingHistoryViewProps {
|
||||
session: AIWritingSessionSummary;
|
||||
}
|
||||
|
||||
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
|
||||
const interventions = session.completed_interventions ?? [];
|
||||
const createdAt = session.created_at
|
||||
? new Date(session.created_at).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{/* 元信息 */}
|
||||
<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>
|
||||
|
||||
{/* 阶段时间线 */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, PenLine } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { getCurrentArtifactContent } from '../../utils/artifact';
|
||||
|
||||
export function AIWritingPageHeader() {
|
||||
const navigate = useNavigate();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const currentContent = getCurrentArtifactContent(artifact);
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => navigate('/page/canvas')}
|
||||
title="返回 Canvas 列表"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
<PenLine className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="text-sm font-semibold">AI 智能写作</span>
|
||||
|
||||
{currentContent?.title && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{currentContent.title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { RotateCcw, Square } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Plus, RotateCcw, Square } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { useAIWriting } from '../../hooks/useAIWriting';
|
||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||
import { AIWritingForm } from './AIWritingForm';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
import { InterventionCard } from './InterventionCard';
|
||||
@@ -12,6 +15,9 @@ import { DraftStreamingView } from './DraftStreamingView';
|
||||
import { OutlineStreamingView } from './OutlineStreamingView';
|
||||
import { ReviewStreamingView } from './ReviewStreamingView';
|
||||
import { RevisionSummaryCard } from './RevisionSummaryCard';
|
||||
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
|
||||
import { AIWritingHistoryView } from './AIWritingHistoryView';
|
||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
@@ -29,33 +35,106 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function AIWritingPanel() {
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const { state, startWriting, submitIntervention, stopWriting, reset } = useAIWriting();
|
||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||
|
||||
// History view state — null means normal writing mode
|
||||
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
|
||||
|
||||
const isIdle = state.status === 'idle';
|
||||
const isDone = state.status === 'done';
|
||||
const isError = state.status === 'error';
|
||||
const isRunning = !isIdle && !isDone && !isError;
|
||||
// 有干预卡时不显示骨架屏(干预卡本身就是内容)
|
||||
const showSkeleton = isRunning && !state.currentPause;
|
||||
|
||||
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
|
||||
setHistorySession(session);
|
||||
// Push historical draft to the right panel
|
||||
if (session.draft_markdown) {
|
||||
setArtifact({
|
||||
currentIndex: 0,
|
||||
contents: [{
|
||||
index: 0,
|
||||
type: 'text',
|
||||
title: session.draft_title ?? session.title,
|
||||
fullMarkdown: session.draft_markdown,
|
||||
}],
|
||||
});
|
||||
} else {
|
||||
setArtifact(null);
|
||||
}
|
||||
}, [setArtifact]);
|
||||
|
||||
const handleNewSession = useCallback(() => {
|
||||
setHistorySession(null);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const showNewButton = historySession !== null || state.status !== 'idle';
|
||||
|
||||
// History view mode
|
||||
if (historySession) {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
|
||||
{historySession.draft_title ?? historySession.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||||
<Plus className="size-3" />
|
||||
新建对话
|
||||
</Button>
|
||||
<AIWritingHistoryDropdown
|
||||
sessions={sessions}
|
||||
selectedId={historySession.id}
|
||||
loading={sessionsLoading}
|
||||
onSelect={handleSelectHistory}
|
||||
onDelete={remove}
|
||||
onRename={rename}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AIWritingHistoryView session={historySession} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<span className="text-sm font-semibold">AI 智能写作</span>
|
||||
<span className="text-sm font-semibold shrink-0">AI 智能写作</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{showNewButton && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||||
<Plus className="size-3" />
|
||||
新建
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={stopWriting}>
|
||||
<Square className="size-3" />
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
{(isDone || isError || isRunning) && (
|
||||
{(isDone || isError) && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
||||
<RotateCcw className="size-3" />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
<AIWritingHistoryDropdown
|
||||
sessions={sessions}
|
||||
selectedId={null}
|
||||
loading={sessionsLoading}
|
||||
onSelect={handleSelectHistory}
|
||||
onDelete={remove}
|
||||
onRename={rename}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,12 +168,12 @@ export function AIWritingPanel() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 大纲流式思考框(有内容时显示,同时隐藏骨架屏) */}
|
||||
{/* 大纲流式思考框 */}
|
||||
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
|
||||
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
|
||||
)}
|
||||
|
||||
{/* 编辑审核流式结果(逐条到达时显示) */}
|
||||
{/* 编辑审核流式结果 */}
|
||||
{state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
|
||||
<ReviewStreamingView items={state.streamingReviewItems} />
|
||||
)}
|
||||
@@ -106,7 +185,7 @@ export function AIWritingPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 草稿流式打字机视图(有内容时显示) */}
|
||||
{/* 草稿流式打字机视图 */}
|
||||
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
|
||||
<DraftStreamingView
|
||||
title={state.streamingDraftTitle}
|
||||
@@ -114,11 +193,6 @@ export function AIWritingPanel() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
骨架屏统一规则:
|
||||
- 正在运行 + 无干预卡 + 非草稿写作阶段(草稿由下方单独处理)
|
||||
- writing_outline 有流式内容时由 OutlineStreamingView 接管,骨架隐藏
|
||||
*/}
|
||||
{showSkeleton &&
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
@@ -128,14 +202,12 @@ export function AIWritingPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 草稿阶段:流式数据未到达前显示骨架 */}
|
||||
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<WritingSkeletonCard status="writing_draft" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 出错提示 */}
|
||||
{isError && state.error && (
|
||||
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{state.error}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
listAIWritingSessions,
|
||||
renameAIWritingSession,
|
||||
deleteAIWritingSession,
|
||||
} from '../api/ai-writing-sessions';
|
||||
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
|
||||
|
||||
export function useAIWritingSessions() {
|
||||
const [sessions, setSessions] = useState<AIWritingSessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listAIWritingSessions();
|
||||
setSessions(data);
|
||||
} catch {
|
||||
// silently ignore — history is non-critical
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rename = useCallback(async (id: string, title: string) => {
|
||||
try {
|
||||
const updated = await renameAIWritingSession(id, title);
|
||||
setSessions((prev) => prev.map((s) => (s.id === id ? updated : s)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const remove = useCallback(async (id: string) => {
|
||||
try {
|
||||
await deleteAIWritingSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { sessions, loading, refresh, rename, remove };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useRef } from 'react';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import type { GroupImperativeHandle } from 'react-resizable-panels';
|
||||
|
||||
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
|
||||
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
|
||||
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
||||
import { AIWritingPageHeader } from '../components/ai-writing/AIWritingPageHeader';
|
||||
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
||||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable';
|
||||
|
||||
// Panel IDs used as keys in the v4 Layout map
|
||||
const WRITING_ID = 'aw-writing';
|
||||
const DRAFT_ID = 'aw-draft';
|
||||
|
||||
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||
|
||||
export default function AIWritingPage() {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<AIWritingPageHeader />
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
groupRef={layoutRef}
|
||||
defaultLayout={DEFAULT_LAYOUT}
|
||||
className="h-full"
|
||||
>
|
||||
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
|
||||
<ResizablePanel
|
||||
id={WRITING_ID}
|
||||
defaultSize={35}
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 右侧:草稿预览面板 */}
|
||||
<ResizablePanel
|
||||
id={DRAFT_ID}
|
||||
defaultSize={65}
|
||||
minSize={40}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<AIWritingDraftPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CanvasUIProvider>
|
||||
</CanvasGraphProvider>
|
||||
</CanvasThreadProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
|
||||
import { FileText, Code2, Sparkles, Loader2, PenLine } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createCanvasThread } from "../api/threads";
|
||||
@@ -86,6 +86,23 @@ export default function CanvasHomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI 智能写作入口 */}
|
||||
<button
|
||||
onClick={() => navigate('ai-writing')}
|
||||
className="group w-full flex items-center gap-4 rounded-xl border border-orange-200 dark:border-orange-900/50 bg-orange-50/50 dark:bg-orange-950/20 p-5 text-left transition-all hover:border-orange-400/60 hover:shadow-md hover:bg-orange-50 dark:hover:bg-orange-950/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-400"
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/40 shrink-0 group-hover:bg-orange-200/80 dark:group-hover:bg-orange-800/40 transition-colors">
|
||||
<PenLine className="w-5 h-5 text-orange-500 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm text-orange-700 dark:text-orange-300">AI 智能写作</p>
|
||||
<p className="text-xs text-orange-600/70 dark:text-orange-400/60 leading-relaxed mt-0.5">
|
||||
多智能体协作——搜集素材、规划大纲、起草正文、编辑审核,全流程自动完成
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-orange-400/60 shrink-0 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import CanvasHomePage from "../pages/CanvasHomePage";
|
||||
import CanvasThreadPage from "../pages/CanvasThreadPage";
|
||||
import AIWritingPage from "../pages/AIWritingPage";
|
||||
|
||||
export default function OpenCanvasRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<CanvasHomePage />} />
|
||||
<Route path="ai-writing" element={<AIWritingPage />} />
|
||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -122,6 +122,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.tag_store = make_tag_store(sf)
|
||||
|
||||
from deerflow.persistence.ai_writing_sessions import make_ai_writing_session_store
|
||||
|
||||
app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
|
||||
|
||||
# Run event store (has its own factory with config-driven backend selection)
|
||||
run_events_config = getattr(config, "run_events", None)
|
||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||
@@ -222,6 +226,11 @@ def get_tag_store(request: Request) -> TagStore:
|
||||
return val
|
||||
|
||||
|
||||
def get_ai_writing_session_store(request: Request):
|
||||
"""Return the AI writing session store, or None when not configured."""
|
||||
return getattr(request.app.state, "ai_writing_session_store", None)
|
||||
|
||||
|
||||
def get_run_context(request: Request) -> RunContext:
|
||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""AI 写作路由 —— 独立于原有路由,前缀 /api/ai-writing/
|
||||
|
||||
POST /api/ai-writing/start 启动写作流程,返回 session_id
|
||||
GET /api/ai-writing/{id}/stream SSE 流(进度/干预/草稿事件)
|
||||
POST /api/ai-writing/{id}/resume 用户干预响应,恢复流程
|
||||
GET /api/ai-writing/{id}/state 查询当前流程状态
|
||||
POST /api/ai-writing/start 启动写作流程,返回 session_id
|
||||
GET /api/ai-writing/{id}/stream SSE 流(进度/干预/草稿事件)
|
||||
POST /api/ai-writing/{id}/resume 用户干预响应,恢复流程
|
||||
GET /api/ai-writing/{id}/state 查询当前流程状态
|
||||
GET /api/ai-writing/sessions 列出用户的会话历史
|
||||
GET /api/ai-writing/sessions/{id} 获取单个历史会话
|
||||
PATCH /api/ai-writing/sessions/{id} 重命名会话标题
|
||||
DELETE /api/ai-writing/sessions/{id} 删除会话
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,9 +16,10 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from langgraph.types import Command
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -40,12 +45,32 @@ class StartWritingResponse(BaseModel):
|
||||
|
||||
class ResumeRequest(BaseModel):
|
||||
action: str
|
||||
pause_point: str = ""
|
||||
approved_material_ids: list[str] = Field(default_factory=list)
|
||||
extra_keywords: list[str] = Field(default_factory=list)
|
||||
edited_outline: Optional[dict] = None
|
||||
outline_feedback: str = ""
|
||||
user_revision_notes: str = ""
|
||||
override_verdict: bool = False
|
||||
selected_issue_indices: Optional[list[int]] = None
|
||||
|
||||
|
||||
class RenameSessionRequest(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
id: str
|
||||
user_id: Optional[str] = None
|
||||
title: str
|
||||
user_intent: Optional[str] = None
|
||||
status: str
|
||||
draft_title: Optional[str] = None
|
||||
draft_markdown: Optional[str] = None
|
||||
completed_interventions: Optional[list] = None
|
||||
review_result: Optional[dict] = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
# ── 内部状态 ──────────────────────────────────────────────────────────────────
|
||||
@@ -56,6 +81,18 @@ _event_queues: dict[str, asyncio.Queue] = {}
|
||||
# session_id → model_name(resume 时复用)
|
||||
_session_model_names: dict[str, str] = {}
|
||||
|
||||
# session_id → AIWritingSessionRepository(for DB saves)
|
||||
_session_repos: dict[str, object] = {}
|
||||
|
||||
# session_id → user_id (str | None, already resolved)
|
||||
_session_user_ids: dict[str, str | None] = {}
|
||||
|
||||
# session_id → accumulated intervention records
|
||||
_session_interventions: dict[str, list] = {}
|
||||
|
||||
# session_id → True if session ended with error
|
||||
_session_error_flags: dict[str, bool] = {}
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -76,17 +113,41 @@ def _make_config(session_id: str, model_name: str = "") -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _truncate_title(text: str, max_len: int = 50) -> str:
|
||||
text = text.strip()
|
||||
return text[:max_len] + ("…" if len(text) > max_len else "")
|
||||
|
||||
|
||||
# ── 路由 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/start", response_model=StartWritingResponse)
|
||||
async def start_writing(body: StartWritingRequest):
|
||||
async def start_writing(body: StartWritingRequest, request: Request):
|
||||
"""启动写作流程,返回 session_id。"""
|
||||
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
|
||||
from app.gateway.deps import get_ai_writing_session_store, get_current_user
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
_event_queues[session_id] = queue
|
||||
_session_model_names[session_id] = body.model_name
|
||||
_session_interventions[session_id] = []
|
||||
|
||||
# 持久化会话记录(DB 可能未配置,则跳过)
|
||||
repo = get_ai_writing_session_store(request)
|
||||
user_id = await get_current_user(request)
|
||||
if repo is not None:
|
||||
try:
|
||||
title = _truncate_title(body.user_intent)
|
||||
await repo.create(
|
||||
id=session_id,
|
||||
title=title,
|
||||
user_intent=body.user_intent,
|
||||
user_id=user_id,
|
||||
)
|
||||
_session_repos[session_id] = repo
|
||||
_session_user_ids[session_id] = user_id
|
||||
except Exception:
|
||||
logger.exception("ai_writing: failed to create session record: session=%s", session_id)
|
||||
|
||||
initial_state = {
|
||||
"user_intent": body.user_intent,
|
||||
@@ -110,6 +171,63 @@ async def start_writing(body: StartWritingRequest):
|
||||
return StartWritingResponse(session_id=session_id)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
async def list_sessions(request: Request):
|
||||
"""列出当前用户的 AI 写作历史会话。"""
|
||||
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 []
|
||||
user_id = await get_current_user(request)
|
||||
return await repo.list(user_id=user_id)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=SessionResponse)
|
||||
async def get_session(session_id: str, 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:
|
||||
raise HTTPException(status_code=503, detail="Session store not available")
|
||||
user_id = await get_current_user(request)
|
||||
record = await repo.get(session_id, user_id=user_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return record
|
||||
|
||||
|
||||
@router.patch("/sessions/{session_id}", response_model=SessionResponse)
|
||||
async def rename_session(session_id: str, body: RenameSessionRequest, 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:
|
||||
raise HTTPException(status_code=503, detail="Session store not available")
|
||||
user_id = await get_current_user(request)
|
||||
updated = await repo.update(session_id, user_id=user_id, title=body.title.strip())
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(session_id: str, 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:
|
||||
raise HTTPException(status_code=503, detail="Session store not available")
|
||||
user_id = await get_current_user(request)
|
||||
deleted = await repo.delete(session_id, user_id=user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/{session_id}/stream")
|
||||
async def stream_progress(session_id: str):
|
||||
"""SSE 流:推送进度事件、干预请求、草稿内容。"""
|
||||
@@ -121,9 +239,10 @@ async def stream_progress(session_id: str):
|
||||
|
||||
|
||||
@router.post("/{session_id}/resume")
|
||||
async def resume_writing(session_id: str, body: ResumeRequest):
|
||||
async def resume_writing(session_id: str, body: ResumeRequest, request: Request):
|
||||
"""用户提交干预决策,恢复暂停的流程。"""
|
||||
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
|
||||
from app.gateway.deps import get_current_user
|
||||
|
||||
if session_id not in _event_queues:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
@@ -137,8 +256,29 @@ async def resume_writing(session_id: str, body: ResumeRequest):
|
||||
"outlineFeedback": body.outline_feedback,
|
||||
"userRevisionNotes": body.user_revision_notes,
|
||||
"overrideVerdict": body.override_verdict,
|
||||
"selectedIssueIndices": body.selected_issue_indices,
|
||||
}
|
||||
|
||||
# Record this intervention for session history
|
||||
record = {
|
||||
"pausePoint": body.pause_point,
|
||||
"action": body.action,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if session_id in _session_interventions:
|
||||
_session_interventions[session_id].append(record)
|
||||
repo = _session_repos.get(session_id)
|
||||
user_id = _session_user_ids.get(session_id)
|
||||
if repo is not None:
|
||||
try:
|
||||
await repo.update(
|
||||
session_id,
|
||||
user_id=user_id,
|
||||
completed_interventions=_session_interventions[session_id],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ai_writing: failed to save intervention: session=%s", session_id)
|
||||
|
||||
asyncio.create_task(
|
||||
_stream_graph_segment(session_id, Command(resume=intervention), config, AI_WRITING_GRAPH)
|
||||
)
|
||||
@@ -174,6 +314,7 @@ async def _stream_graph_segment(
|
||||
return
|
||||
|
||||
reached_end = False
|
||||
is_error = False
|
||||
try:
|
||||
async for chunk in graph.astream(input_or_command, config=config, stream_mode="updates"):
|
||||
node_name = next(iter(chunk))
|
||||
@@ -208,18 +349,48 @@ async def _stream_graph_segment(
|
||||
if outline := node_output.get("current_outline"):
|
||||
await queue.put(("outline_ready", {"session_id": session_id, "outline": outline}))
|
||||
|
||||
# 草稿就绪
|
||||
# 草稿就绪 — 保存到 DB
|
||||
if draft := node_output.get("current_draft"):
|
||||
draft_title = draft.get("title", "")
|
||||
draft_markdown = draft.get("full_markdown", "")
|
||||
await queue.put(("draft_ready", {
|
||||
"session_id": session_id,
|
||||
"title": draft.get("title", ""),
|
||||
"markdown": draft.get("full_markdown", ""),
|
||||
"title": draft_title,
|
||||
"markdown": draft_markdown,
|
||||
"revision_count": draft.get("revision_count", 0),
|
||||
}))
|
||||
repo = _session_repos.get(session_id)
|
||||
user_id = _session_user_ids.get(session_id)
|
||||
if repo is not None:
|
||||
try:
|
||||
await repo.update(
|
||||
session_id,
|
||||
user_id=user_id,
|
||||
status="draft_ready",
|
||||
title=draft_title or _truncate_title(
|
||||
next(iter(_session_interventions.get(session_id, [{}])), {}).get("action", "草稿"),
|
||||
),
|
||||
draft_title=draft_title,
|
||||
draft_markdown=draft_markdown,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ai_writing: failed to save draft: session=%s", session_id)
|
||||
|
||||
# 编辑审核就绪
|
||||
# 编辑审核就绪 — 保存到 DB
|
||||
if review := node_output.get("review_result"):
|
||||
await queue.put(("review_ready", {"session_id": session_id, "review_result": review}))
|
||||
repo = _session_repos.get(session_id)
|
||||
user_id = _session_user_ids.get(session_id)
|
||||
if repo is not None:
|
||||
try:
|
||||
await repo.update(
|
||||
session_id,
|
||||
user_id=user_id,
|
||||
status="review_ready",
|
||||
review_result=review,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ai_writing: failed to save review: session=%s", session_id)
|
||||
|
||||
# 状态 done:Graph 结束
|
||||
if node_output.get("status") == "done":
|
||||
@@ -232,12 +403,30 @@ async def _stream_graph_segment(
|
||||
except Exception as e:
|
||||
logger.exception("ai_writing graph segment failed: session=%s", session_id)
|
||||
await queue.put(("error", {"session_id": session_id, "message": str(e)}))
|
||||
is_error = True
|
||||
reached_end = True
|
||||
finally:
|
||||
if reached_end:
|
||||
# 更新最终状态到 DB
|
||||
repo = _session_repos.get(session_id)
|
||||
user_id = _session_user_ids.get(session_id)
|
||||
if repo is not None:
|
||||
try:
|
||||
await repo.update(
|
||||
session_id,
|
||||
user_id=user_id,
|
||||
status="error" if is_error else "done",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ai_writing: failed to update final status: session=%s", session_id)
|
||||
|
||||
await queue.put(None) # 哨兵,通知 SSE 生成器关闭
|
||||
_event_queues.pop(session_id, None)
|
||||
_session_model_names.pop(session_id, None)
|
||||
_session_repos.pop(session_id, None)
|
||||
_session_user_ids.pop(session_id, None)
|
||||
_session_interventions.pop(session_id, None)
|
||||
_session_error_flags.pop(session_id, None)
|
||||
|
||||
|
||||
def _event_type(evt: dict) -> str:
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
from deerflow.persistence.ai_writing_sessions.model import AIWritingSessionRow
|
||||
from deerflow.persistence.ai_writing_sessions.sql import AIWritingSessionRepository
|
||||
|
||||
|
||||
def make_ai_writing_session_store(session_factory) -> AIWritingSessionRepository | None:
|
||||
if session_factory is None:
|
||||
return None
|
||||
return AIWritingSessionRepository(session_factory)
|
||||
|
||||
|
||||
__all__ = ["AIWritingSessionRow", "AIWritingSessionRepository", "make_ai_writing_session_store"]
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"""ORM model for AI writing sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class AIWritingSessionRow(Base):
|
||||
__tablename__ = "ai_writing_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
user_intent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="in_progress")
|
||||
draft_title: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
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
|
||||
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))
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
"""SQLAlchemy-backed AI writing session storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.ai_writing_sessions.model import AIWritingSessionRow
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
class AIWritingSessionRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: AIWritingSessionRow) -> dict:
|
||||
d = row.to_dict()
|
||||
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"):
|
||||
val = d.get(k)
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
d[k] = json.loads(val)
|
||||
except Exception:
|
||||
d[k] = None
|
||||
return d
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
id: str,
|
||||
title: str,
|
||||
user_intent: str | None = None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> dict:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.create")
|
||||
now = datetime.now(UTC)
|
||||
row = AIWritingSessionRow(
|
||||
id=id,
|
||||
user_id=resolved_user_id,
|
||||
title=title,
|
||||
user_intent=user_intent,
|
||||
status="in_progress",
|
||||
draft_title=None,
|
||||
draft_markdown=None,
|
||||
completed_interventions=None,
|
||||
review_result=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
title=_UNSET,
|
||||
status=_UNSET,
|
||||
draft_title=_UNSET,
|
||||
draft_markdown=_UNSET,
|
||||
completed_interventions=_UNSET,
|
||||
review_result=_UNSET,
|
||||
) -> dict | None:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.update")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AIWritingSessionRow, session_id)
|
||||
if row is None:
|
||||
return None
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return None
|
||||
if title is not _UNSET:
|
||||
row.title = title
|
||||
if status is not _UNSET:
|
||||
row.status = status
|
||||
if draft_title is not _UNSET:
|
||||
row.draft_title = draft_title
|
||||
if draft_markdown is not _UNSET:
|
||||
row.draft_markdown = draft_markdown
|
||||
if completed_interventions is not _UNSET:
|
||||
row.completed_interventions = (
|
||||
json.dumps(completed_interventions, ensure_ascii=False)
|
||||
if completed_interventions is not None
|
||||
else None
|
||||
)
|
||||
if review_result is not _UNSET:
|
||||
row.review_result = (
|
||||
json.dumps(review_result, ensure_ascii=False)
|
||||
if review_result is not None
|
||||
else None
|
||||
)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> dict | None:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.get")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AIWritingSessionRow, session_id)
|
||||
if row is None:
|
||||
return None
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.list")
|
||||
stmt = select(AIWritingSessionRow)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(AIWritingSessionRow.user_id == resolved_user_id)
|
||||
stmt = stmt.order_by(AIWritingSessionRow.updated_at.desc()).limit(limit)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> bool:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.delete")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AIWritingSessionRow, session_id)
|
||||
if row is None:
|
||||
return False
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
"""Create ai_writing_sessions table.
|
||||
|
||||
Revision ID: 20260517_01
|
||||
Revises: 20260515_01
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260517_01"
|
||||
down_revision = "20260515_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("ai_writing_sessions"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"ai_writing_sessions",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("user_id", sa.String(64), nullable=True),
|
||||
sa.Column("title", sa.String(512), nullable=False, server_default=""),
|
||||
sa.Column("user_intent", sa.String(512), nullable=True),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="in_progress"),
|
||||
sa.Column("draft_title", sa.String(512), nullable=True),
|
||||
sa.Column("draft_markdown", sa.Text, nullable=True),
|
||||
sa.Column("completed_interventions", sa.Text, nullable=True),
|
||||
sa.Column("review_result", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_ai_writing_sessions_user_id", "ai_writing_sessions", ["user_id"])
|
||||
op.create_index("ix_ai_writing_sessions_updated_at", "ai_writing_sessions", ["updated_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("ai_writing_sessions"):
|
||||
op.drop_table("ai_writing_sessions")
|
||||
Reference in new issue
Block a user