Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx
T
2026-05-22 21:41:26 +08:00

568 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWritingStream';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
/**
* 暂停 / 继续功能开关(默认 false = 关)。
*
* 历史背景:legacy 路径下,为了让用户点"停止"后还能"继续",引入了 per-worker
* 内存 dict(``_event_queues`` / ``_session_pending_pause``)。多 worker 部署下
* 这些 dict 互相看不见 → SSE 重连失败 / "会话丢失" → 触发了整个 ai-writing-优化.md
* 的迁移计划。
*
* 当前 LangGraph Server 路径下架构已无 per-worker 状态,但 ``stream.stop()`` 仅
* 取消客户端 SSE 订阅,**不会真正暂停后端 run**(LLM 继续烧 token);``resume``
* 也无法重新订阅同一个 run。这是个产品语义没想清楚的功能,先关掉。
*
* 后期想清楚怎么做"真·暂停"(``client.runs.cancel(run_id, action='interrupt')``
* 保留 checkpoint)+ "真·继续"(``client.runs.joinStream`` 续上原 run)时,
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
*/
const ENABLE_PAUSE_RESUME_UI = false;
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
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: '就绪',
researching: '素材收集专家搜集素材中…',
awaiting_material_confirm: '等待确认素材',
writing_outline: '作家规划大纲中…',
awaiting_outline_confirm: '等待确认大纲',
writing_draft: '作家写作中…',
awaiting_section_help: '等待处理素材不足的章节',
awaiting_draft_confirm: '等待确认草稿',
reviewing: '编辑审核中…',
awaiting_review_confirm: '等待处理编辑意见',
revising: '按意见修改中…',
done: '写作完成 ✓',
error: '出错了',
};
export function AIWritingPanel({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
// History view state — null means normal writing mode
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
const [reqDialogOpen, setReqDialogOpen] = useState(false);
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 && !state.isPaused;
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
const isAutoScrollingRef = useRef(false);
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
const autoScrollRef = useRef(true);
const [autoScroll, setAutoScroll] = useState(true);
const disableAutoScroll = useCallback(() => {
autoScrollRef.current = false;
setAutoScroll(false);
}, []);
// 新会话 / 重置后恢复自动滚底
useEffect(() => {
if (isIdle) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isIdle]);
// 开始写作 / 点击继续后恢复自动滚底
useEffect(() => {
if (isRunning && !state.isPaused) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isRunning, state.isPaused]);
// 监听用户手动滚动(挂载一次)
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
};
// scroll 事件:兼容拖动滚动条的场景
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
let lastTop = el.scrollTop;
const onScroll = () => {
const cur = el.scrollTop;
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
disableAutoScroll();
}
lastTop = cur;
};
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('scroll', onScroll);
};
}, [disableAutoScroll]);
// 内容变化时若自动滚底开启则滚到底部
useEffect(() => {
// 检查同步 ref,避免 state 异步导致的时序竞争
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
const el = scrollRef.current;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
}, [
autoScroll, // 点击按钮后重新触发一次
isRunning,
state.progressEvents.length,
state.streamingDraftSections.length,
state.streamingReviewItems.length,
state.outlineStreamingText,
state.currentPause,
]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
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]);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
const handleSelectHistoryRef = useRef(handleSelectHistory);
useEffect(() => {
handleSelectHistoryRef.current = handleSelectHistory;
}, [handleSelectHistory]);
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
useEffect(() => {
const raw = sessionStorage.getItem('ai-writing:pending-session');
if (!raw) return;
sessionStorage.removeItem('ai-writing:pending-session');
try {
const session = JSON.parse(raw) as AIWritingSessionSummary;
handleSelectHistoryRef.current(session);
} catch { /* ignore */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
useEffect(() => {
function onOpenSession(e: Event) {
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
if (session) handleSelectHistoryRef.current(session);
}
window.addEventListener('ai-writing:open-session', onOpenSession);
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
}, []);
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
const handleAdminCleanup = useCallback(async () => {
if (!window.confirm('确认清理:retention_days=0 会清掉所有 done/error 的历史会话,正在跑的会跳过。继续?')) {
return;
}
try {
const result = await adminCleanupAIWritingSessions({ retention_days: 0, only_finished: true });
toast.success(`已清理:DB ${result.deleted_db} 行,checkpoint ${result.deleted_checkpoints} 条,跳过活跃 ${result.skipped_active}`);
// 刷新历史列表
refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
}
}, [refresh]);
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="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
<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={cn(
'text-sm font-semibold shrink-0',
!isIdle && 'cursor-pointer hover:text-primary transition-colors',
)}
onClick={() => { if (!isIdle && state.request) setReqDialogOpen(true); }}
>
AI 智能写作
</span>
<div className="flex items-center gap-1.5">
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={handleAdminCleanup}
title="[admin 调试] 清理所有过期会话"
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
{showNewButton && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建
</Button>
)}
{/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。
后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */}
{ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={pauseWriting}
>
<Square className="size-3 fill-current" />
停止
</Button>
)}
{ENABLE_PAUSE_RESUME_UI && state.isPaused && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
onClick={resumeWriting}
>
<Play className="size-3 fill-current" />
继续
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
</>
)}
{(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>
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
/>
)}
{/* 意图解析:流式 → 结果卡片承接。
放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长,
如果把意图块塞在 timeline 下面就会随 timeline 增长被推走;
放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */}
{(state.intentStreamingText || state.intentThinkingText) && (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
/>
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
<AIWritingTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
currentInterventionSlot={
state.currentPause ? (
<InterventionCard
pauseEvent={state.currentPause}
materials={state.materials}
outline={state.outline}
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
revisionCount={state.revisionCount}
onSubmit={submitIntervention}
disabled={false}
/>
) : undefined
}
/>
)}
{/* 大纲流式思考框 */}
{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} />
)}
{/* 修改完成摘要 */}
{state.revisionSummary && (
<div className="px-4 pb-2">
<RevisionSummaryCard summary={state.revisionSummary} />
</div>
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
)}
{showSkeleton &&
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
</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}
</div>
)}
</div>{/* 滚动内容区结束 */}
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
{isRunning && !autoScroll && (
<div className="absolute inset-x-0 bottom-4 flex justify-center z-10 pointer-events-none">
<button
onClick={scrollToBottom}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-full bg-background border shadow-md hover:bg-muted transition-colors whitespace-nowrap"
>
<ChevronsDown className="size-3.5" />
回到底部
</button>
</div>
)}
</div>{/* 相对定位容器结束 */}
{/* 状态栏 */}
{!isIdle && (
<div
className={cn(
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{ENABLE_PAUSE_RESUME_UI && state.isPaused ? (
<>
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
已暂停
</>
) : (
<>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{state.status === 'researching' && state.researchProgress
? state.researchProgress
: STATUS_LABEL[state.status] ?? state.status}
</>
)}
</div>
)}
{/* 写作参数查看弹窗 */}
{state.request && (
<Dialog open={reqDialogOpen} onOpenChange={setReqDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>本次写作参数</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1 text-sm">
<ReqRow label="写作意图">
<p className="text-muted-foreground whitespace-pre-wrap leading-relaxed">{state.request.userIntent}</p>
</ReqRow>
<ReqRow label="文章类型">
<span className="text-muted-foreground">
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
</span>
</ReqRow>
{!hideAdvancedFields && (
<ReqRow label="目标读者">
<span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
)}
<ReqRow label="目标字数">
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
</ReqRow>
{!hideAdvancedFields && (
<>
<ReqRow label="检索关键词数量">
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
</ReqRow>
<ReqRow label="写作模式">
<span className="text-muted-foreground">
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
</>
)}
{state.request.modelName && (
<ReqRow label="写作模型">
<span className="text-muted-foreground">{state.request.modelName}</span>
</ReqRow>
)}
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}
function ReqRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-0.5">
<p className="text-xs font-medium text-foreground/60">{label}</p>
{children}
</div>
);
}