From 918e65d8d3d1a51dcdb13e98436d1d02b3976e55 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 19:29:10 +0800 Subject: [PATCH] =?UTF-8?q?ai=E5=86=99=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../open-canvas/api/ai-writing-sessions.ts | 51 +++++ .../ai-writing/AIWritingDraftPanel.tsx | 59 +++++ .../ai-writing/AIWritingHistoryDropdown.tsx | 202 +++++++++++++++++ .../ai-writing/AIWritingHistoryView.tsx | 115 ++++++++++ .../ai-writing/AIWritingPageHeader.tsx | 41 ++++ .../components/ai-writing/AIWritingPanel.tsx | 100 +++++++-- .../open-canvas/hooks/useAIWritingSessions.ts | 45 ++++ .../src/open-canvas/pages/AIWritingPage.tsx | 74 ++++++ .../src/open-canvas/pages/CanvasHomePage.tsx | 19 +- .../open-canvas/routes/OpenCanvasRoutes.tsx | 2 + .../backend/app/gateway/deps.py | 9 + .../backend/app/gateway/routers/ai_writing.py | 211 +++++++++++++++++- .../ai_writing_sessions/__init__.py | 11 + .../persistence/ai_writing_sessions/model.py | 26 +++ .../persistence/ai_writing_sessions/sql.py | 154 +++++++++++++ .../20260517_01_ai_writing_sessions.py | 50 +++++ 16 files changed, 1143 insertions(+), 26 deletions(-) create mode 100644 frontend-web/src/open-canvas/api/ai-writing-sessions.ts create mode 100644 frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx create mode 100644 frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx create mode 100644 frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx create mode 100644 frontend-web/src/open-canvas/components/ai-writing/AIWritingPageHeader.tsx create mode 100644 frontend-web/src/open-canvas/hooks/useAIWritingSessions.ts create mode 100644 frontend-web/src/open-canvas/pages/AIWritingPage.tsx create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260517_01_ai_writing_sessions.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 new file mode 100644 index 00000000..ac535d52 --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing-sessions.ts @@ -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 | 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 { + const resp = await apiFetch(BASE()); + if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`); + return resp.json(); +} + +export async function getAIWritingSession(id: string): Promise { + 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 { + 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 { + const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' }); + if (!resp.ok) throw new Error(`删除失败: ${resp.status}`); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx new file mode 100644 index 00000000..dc39da2e --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx @@ -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 ( +
+
+
+ +
+
+ +
+
+
+

草稿将在此处显示

+

+ 完成写作流程后,AI 生成的内容将实时出现在这里 +

+
+
+ ); +} + +export function AIWritingDraftPanel() { + const { artifact } = useCanvasGraphContext(); + + if (!artifact) { + return ( +
+ +
+ ); + } + + const content = getCurrentArtifactContent(artifact); + + return ( +
+ {/* 标题栏 */} +
+ + + {content?.title ?? '草稿预览'} + +
+ +
+ {content && !isCodeArtifact(content) ? ( + + ) : null} +
+
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx new file mode 100644 index 00000000..6d4c925e --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx @@ -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 = { + 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(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 ( +
+
+ {editing ? ( +
e.stopPropagation()}> + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + /> + + +
+ ) : ( +

{ e.stopPropagation(); setEditing(true); }} + title="双击重命名" + > + {session.title} +

+ )} +
+ {label} + · + {formatRelative(session.updated_at)} +
+
+ + {/* 删除按钮,hover 时显示 */} + {!editing && ( + + )} +
+ ); +} + +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(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 ( +
+ + + {open && ( +
+ {/* 头部 */} +
+ 写作历史 + +
+ + {/* 列表 */} +
+ {sessions.length === 0 ? ( +
+ {loading ? '加载中…' : '暂无历史记录'} +
+ ) : ( + sessions.map((s) => ( + { onSelect(s); setOpen(false); }} + onDelete={() => onDelete(s.id)} + onRename={(title) => onRename(s.id, title)} + /> + )) + )} +
+
+ )} +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx new file mode 100644 index 00000000..e1e46d1c --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx @@ -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 = { + material_confirm: '素材确认', + outline_confirm: '大纲确认', + draft_confirm: '草稿确认', + review_confirm: '编辑意见处理', +}; + +const ACTION_LABELS: Record = { + approve: '已批准', + re_search: '重新搜索', + re_outline: '重新规划', + finalize: '直接定稿', + to_editor: '提交审核', + user_revise: '手动修改', + accept_review: '接受意见', + force_finalize: '强制定稿', +}; + +const PAUSE_ICONS: Record = { + 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 ( +
+
+
+ +
+ {!isLast &&
} +
+
+
+ + {PAUSE_LABELS[record.pausePoint] ?? record.pausePoint} + + + + {ACTION_LABELS[record.action] ?? record.action} + +
+ {ts &&

{ts}

} +
+
+ ); +} + +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 ( +
+ {/* 元信息 */} +
+

{session.draft_title ?? session.title}

+ {session.user_intent && ( +

+ {session.user_intent} +

+ )} + {createdAt && ( +

{createdAt}

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

写作阶段

+
+ {interventions.map((rec, i) => ( + + ))} +
+
+ ) : ( +
无阶段记录
+ )} + + {/* 完成状态 */} + {session.status === 'done' && ( +
+ + 写作完成 +
+ )} +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPageHeader.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPageHeader.tsx new file mode 100644 index 00000000..0925c3f6 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPageHeader.tsx @@ -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 ( +
+ + + + + + AI 智能写作 + + {currentContent?.title && ( + <> + + + {currentContent.title} + + + )} +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index aa26f917..c60296ce 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -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 = { idle: '就绪', @@ -29,33 +35,106 @@ const STATUS_LABEL: Record = { }; 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(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 ( +
+
+ + {historySession.draft_title ?? historySession.title} + +
+ + +
+
+ +
+ ); + } + return (
{/* 头部 */}
- AI 智能写作 + AI 智能写作
+ {showNewButton && ( + + )} {isRunning && ( )} - {(isDone || isError || isRunning) && ( + {(isDone || isError) && ( )} +
@@ -89,12 +168,12 @@ export function AIWritingPanel() { /> )} - {/* 大纲流式思考框(有内容时显示,同时隐藏骨架屏) */} + {/* 大纲流式思考框 */} {state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && ( )} - {/* 编辑审核流式结果(逐条到达时显示) */} + {/* 编辑审核流式结果 */} {state.status === 'reviewing' && state.streamingReviewItems.length > 0 && ( )} @@ -106,7 +185,7 @@ export function AIWritingPanel() {
)} - {/* 草稿流式打字机视图(有内容时显示) */} + {/* 草稿流式打字机视图 */} {state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && ( )} - {/* - 骨架屏统一规则: - - 正在运行 + 无干预卡 + 非草稿写作阶段(草稿由下方单独处理) - - writing_outline 有流式内容时由 OutlineStreamingView 接管,骨架隐藏 - */} {showSkeleton && state.status !== 'writing_draft' && !(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) && @@ -128,14 +202,12 @@ export function AIWritingPanel() {
)} - {/* 草稿阶段:流式数据未到达前显示骨架 */} {state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
)} - {/* 出错提示 */} {isError && state.error && (
{state.error} diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingSessions.ts b/frontend-web/src/open-canvas/hooks/useAIWritingSessions.ts new file mode 100644 index 00000000..ffa7ae6d --- /dev/null +++ b/frontend-web/src/open-canvas/hooks/useAIWritingSessions.ts @@ -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([]); + 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 }; +} diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx new file mode 100644 index 00000000..d2ebe25c --- /dev/null +++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx @@ -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(null); + + return ( + + + + +
+ + +
+ + {/* 左侧:AI 写作面板,与原画布页左侧等宽 */} + + {/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */} +
+ +
+
+ + + + {/* 右侧:草稿预览面板 */} + + + +
+
+
+
+
+
+
+ ); +} diff --git a/frontend-web/src/open-canvas/pages/CanvasHomePage.tsx b/frontend-web/src/open-canvas/pages/CanvasHomePage.tsx index 35970cc5..ea95f610 100644 --- a/frontend-web/src/open-canvas/pages/CanvasHomePage.tsx +++ b/frontend-web/src/open-canvas/pages/CanvasHomePage.tsx @@ -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() { ))}
+ {/* AI 智能写作入口 */} + +