feat: AI writing history, Word export, roundtable dispatch chain

This commit is contained in:
fengxiangdebaozi committed 2026-06-04 15:40:15 +08:00
1 parent c0e3c5d692
commit 10334ddfe1
21 files changed
+1888 -143

No files matched your search

@@ -1,69 +1,12 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
function sanitizeFallbackFilename(title: string | undefined): string {
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
}
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "导出 Word 失败" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("导出 Word 失败");
}
const blob = await response.blob();
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackFilename;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
/** AI 写作草稿导出 Word(走通用 `/api/writing/export/docx`)。 */
export async function downloadAIWritingDocx(options: {
title?: string;
draftMarkdown: string;
}): Promise<void> {
const draftMarkdown = options.draftMarkdown.trim();
if (!draftMarkdown) {
throw new Error("文档内容为空");
}
const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: options.title?.trim() || undefined,
draft_markdown: draftMarkdown,
}),
await downloadMarkdownDocxFromBackend({
title: options.title,
markdown: options.draftMarkdown,
});
await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
}
@@ -1,6 +1,7 @@
import { FileText, PenLine } from 'lucide-react';
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
import { useAIWritingHistoryOptional } from '../../contexts/AIWritingHistoryContext';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
import { assembleStreamingDraftMarkdown } from '../../hooks/useAIWritingStream';
@@ -32,8 +33,12 @@ function DraftWaitingPlaceholder() {
export function AIWritingDraftPanel() {
const writing = useAIWritingOptional();
const history = useAIWritingHistoryOptional();
const isHistoryMode = history?.isHistoryMode ?? false;
const { artifact } = useCanvasGraphContext();
const { isDraftEditable } = useCanvasUIContext();
// 历史回看与写作完成态一致:Tiptap + 目录;流式生成中仍走 Markdown 预览
const showCompletedDraftView = isDraftEditable || isHistoryMode;
const writingStatus = writing?.state.status;
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
@@ -96,7 +101,8 @@ export function AIWritingDraftPanel() {
<div className="flex-1 overflow-hidden">
<TextArtifactRenderer
content={textContent}
isStreaming={!isDraftEditable}
isStreaming={!showCompletedDraftView}
readOnly={isHistoryMode}
/>
</div>
</div>
@@ -13,8 +13,8 @@ import {
} from '@/components/ui/dialog';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
@@ -74,15 +74,13 @@ export function AIWritingPanel({
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
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';
@@ -178,27 +176,13 @@ export function AIWritingPanel({
}, []);
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]);
void openHistory(session);
}, [openHistory]);
const handleNewSession = useCallback(() => {
setHistorySession(null);
closeHistory();
reset();
}, [reset]);
}, [closeHistory, reset]);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
@@ -246,60 +230,27 @@ export function AIWritingPanel({
}
}, [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">
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
<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>
);
}
const showNewButton = isHistoryMode || state.status !== 'idle';
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
{!isHistoryMode && (
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
)}
{/* 头部 */}
<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',
'text-sm font-semibold shrink-0 min-w-0 truncate',
isHistoryMode && 'flex-1 mr-2',
!isIdle && !isHistoryMode && 'cursor-pointer hover:text-primary transition-colors',
)}
onClick={() => { if (!isIdle && state.request) setReqDialogOpen(true); }}
onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }}
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
>
AI 智能写作
{isHistoryMode
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
: 'AI 智能写作'}
</span>
<div className="flex items-center gap-1.5">
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
@@ -365,7 +316,7 @@ export function AIWritingPanel({
)}
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={null}
selectedId={historySession?.id ?? null}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
@@ -378,8 +329,13 @@ export function AIWritingPanel({
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 历史回看:左侧时间线(与实时写作同一套 AIWritingTimeline) */}
{isHistoryMode && historySession && (
<AIWritingHistoryView session={historySession} />
)}
{/* 未开始时显示表单 */}
{isIdle && (
{isIdle && !isHistoryMode && (
<AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
@@ -402,7 +358,7 @@ export function AIWritingPanel({
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
{!isIdle && !isHistoryMode && (
<AIWritingTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
@@ -507,7 +463,7 @@ export function AIWritingPanel({
</div>{/* 相对定位容器结束 */}
{/* 状态栏 */}
{!isIdle && (
{!isIdle && !isHistoryMode && (
<div
className={cn(
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
@@ -22,6 +22,8 @@ import type { RewriteAction, RewriteTone } from "../../api/rewrite";
interface TextArtifactRendererProps {
content: CanvasArtifactText;
isStreaming?: boolean;
/** 完成态只读(如历史回看):保留目录 + 排版,禁用编辑与 AI 润色 */
readOnly?: boolean;
}
interface PanelPos {
@@ -38,7 +40,7 @@ interface LastAction {
customInstruction?: string;
}
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: TextArtifactRendererProps) {
const { artifact, setArtifact } = useCanvasGraphContext();
const { writingSelection } = useCanvasUIContext();
@@ -72,7 +74,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
const editor = useWritingEditor({
markdown: content.fullMarkdown,
onChange: handleEditorChange,
editable: !isStreaming,
editable: !isStreaming && !readOnly,
placeholder: "开始写作…",
});
@@ -270,10 +272,15 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
);
}
// ── Edit mode ─────────────────────────────────────────────────────────
// ── Edit mode(写作完成态 / 历史回看只读)────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden">
<WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
<WritingToolbar
editor={editor}
markdown={content.fullMarkdown}
title={content.title}
readOnly={readOnly}
/>
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
@@ -282,7 +289,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
{/* 文档滚动区 */}
<div ref={setDocScrollEl} className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-10 pt-10">
{editor && (
{editor && !readOnly && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
@@ -294,7 +301,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
</div>
{/* Phase: AI action menu */}
{phase === "menu" && (
{!readOnly && phase === "menu" && (
<WritingAiMenu
pos={panelPos}
status={status}
@@ -307,7 +314,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
)}
{/* Phase: result preview */}
{phase === "preview" && (
{!readOnly && phase === "preview" && (
<WritingRewritePreview
pos={panelPos}
status={status}
@@ -69,9 +69,11 @@ interface WritingToolbarProps {
markdown?: string;
/** 文档标题,用作 .docx 文件名与封面 */
title?: string;
/** 历史回看等场景:仅保留导出,隐藏格式工具 */
readOnly?: boolean;
}
export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
export function WritingToolbar({ editor, markdown, title, readOnly = false }: WritingToolbarProps) {
const [exporting, setExporting] = useState(false);
if (!editor) return null;
@@ -94,6 +96,42 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
}
};
if (readOnly) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0">
<span className="text-xs text-muted-foreground">历史文稿(只读)</span>
<div className="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
void handleExport();
}}
disabled={!canExport}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
{exporting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
导出 Word
</TooltipContent>
</Tooltip>
</div>
</div>
);
}
return (
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
{/* Block type */}
@@ -0,0 +1,82 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import { getAIWritingSession } from '../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
import { useCanvasGraphContext } from './CanvasGraphContext';
import { useCanvasUIContext } from './CanvasUIContext';
type AIWritingHistoryContextValue = {
historySession: AIWritingSessionSummary | null;
isHistoryMode: boolean;
openHistory: (session: AIWritingSessionSummary) => Promise<void>;
closeHistory: () => void;
};
const AIWritingHistoryContext = createContext<AIWritingHistoryContextValue | null>(null);
export function AIWritingHistoryProvider({ children }: { children: ReactNode }) {
const { setArtifact } = useCanvasGraphContext();
const { setIsDraftEditable } = useCanvasUIContext();
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
const openHistory = useCallback(async (session: AIWritingSessionSummary) => {
let full = session;
if (!session.draft_markdown || !session.transcript) {
try {
full = await getAIWritingSession(session.id);
} catch {
// 列表项已够用,拉详情失败则继续用摘要
}
}
setHistorySession(full);
// 完成态布局(Tiptap + 左侧目录),由 DraftPanel 根据 isHistoryMode 只读展示
setIsDraftEditable(true);
if (full.draft_markdown) {
setArtifact({
currentIndex: 0,
contents: [{
index: 0,
type: 'text',
title: full.draft_title ?? full.title,
fullMarkdown: full.draft_markdown,
}],
});
} else {
setArtifact(null);
}
}, [setArtifact, setIsDraftEditable]);
const closeHistory = useCallback(() => {
setHistorySession(null);
setArtifact(null);
setIsDraftEditable(true);
}, [setArtifact, setIsDraftEditable]);
const value = useMemo(
() => ({
historySession,
isHistoryMode: historySession !== null,
openHistory,
closeHistory,
}),
[historySession, openHistory, closeHistory],
);
return (
<AIWritingHistoryContext.Provider value={value}>
{children}
</AIWritingHistoryContext.Provider>
);
}
export function useAIWritingHistory() {
const ctx = useContext(AIWritingHistoryContext);
if (!ctx) {
throw new Error('useAIWritingHistory must be used within AIWritingHistoryProvider');
}
return ctx;
}
export function useAIWritingHistoryOptional() {
return useContext(AIWritingHistoryContext);
}
@@ -92,6 +92,7 @@ import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider } from '../contexts/WritingFormContext';
import {
ResizableHandle,
@@ -131,8 +132,10 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
if (state.status === 'idle') {
// 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿)
if (state.status === 'idle' && !isHistoryMode) {
return (
<ResizablePanelGroup
orientation="horizontal"
@@ -206,6 +209,7 @@ export default function AIWritingPage({
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
@@ -217,6 +221,7 @@ export default function AIWritingPage({
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</AIWritingHistoryProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>