feat(ai写作): 历史回看右侧成稿可编辑并保存回历史记录

- 后端新增 PUT /api/ai-writing/sessions/{id}/draft(属主校验,draft_title 可选)
- 前端历史模式放开只读,工具栏新增「保存」按钮持久化修改
- 实时写作完成态保持原样(仅导出,不落库)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-24 18:32:11 +08:00
1 parent 304e1b637a
commit 42f55251f2
7 files changed
+126 -6

No files matched your search

@@ -82,6 +82,27 @@ export async function renameAIWritingSession(id: string, title: string): Promise
return resp.json();
}
/**
* 更新历史会话的成稿正文(历史回看时用户在右侧编辑器里直接修改并保存)。
* draftTitle 省略时后端不动原标题。
*/
export async function updateAIWritingDraft(
id: string,
draftMarkdown: string,
draftTitle?: string,
): Promise<AIWritingSessionSummary> {
const resp = await apiFetch(`${BASE()}/${id}/draft`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
draft_markdown: draftMarkdown,
...(draftTitle === undefined ? {} : { draft_title: draftTitle }),
}),
});
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}`);
@@ -102,7 +102,8 @@ export function AIWritingDraftPanel() {
<TextArtifactRenderer
content={textContent}
isStreaming={!showCompletedDraftView}
readOnly={isHistoryMode}
readOnly={false}
onSaveDraft={isHistoryMode ? history?.saveHistoryDraft : undefined}
/>
</div>
</div>
@@ -24,6 +24,8 @@ interface TextArtifactRendererProps {
isStreaming?: boolean;
/** 完成态只读(如历史回看):保留目录 + 排版,禁用编辑与 AI 润色 */
readOnly?: boolean;
/** 历史回看可编辑场景:提供后工具栏出现「保存」按钮,把修改持久化回会话 */
onSaveDraft?: (markdown: string, title?: string) => Promise<void>;
}
interface PanelPos {
@@ -40,7 +42,7 @@ interface LastAction {
customInstruction?: string;
}
export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: TextArtifactRendererProps) {
export function TextArtifactRenderer({ content, isStreaming, readOnly = false, onSaveDraft }: TextArtifactRendererProps) {
const { artifact, setArtifact } = useCanvasGraphContext();
const { writingSelection } = useCanvasUIContext();
@@ -280,6 +282,7 @@ export function TextArtifactRenderer({ content, isStreaming, readOnly = false }:
markdown={content.fullMarkdown}
title={content.title}
readOnly={readOnly}
onSave={onSaveDraft}
/>
<div className="flex-1 flex min-h-0 overflow-hidden">
@@ -14,6 +14,7 @@ import {
Pilcrow,
Download,
Loader2,
Save,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
@@ -71,14 +72,32 @@ interface WritingToolbarProps {
title?: string;
/** 历史回看等场景:仅保留导出,隐藏格式工具 */
readOnly?: boolean;
/** 提供后显示「保存」按钮,把当前正文持久化(历史回看可编辑场景) */
onSave?: (markdown: string, title?: string) => Promise<void>;
}
export function WritingToolbar({ editor, markdown, title, readOnly = false }: WritingToolbarProps) {
export function WritingToolbar({ editor, markdown, title, readOnly = false, onSave }: WritingToolbarProps) {
const [exporting, setExporting] = useState(false);
const [saving, setSaving] = useState(false);
if (!editor) return null;
const canExport = !exporting && !!markdown?.trim();
const canSave = !!onSave && !saving && !!markdown?.trim();
const handleSave = async () => {
if (!onSave || saving || !markdown?.trim()) return;
setSaving(true);
try {
await onSave(markdown, title);
toast.success("已保存修改");
} catch (err) {
console.error("[writing-save] failed", err);
toast.error("保存失败,请稍后重试");
} finally {
setSaving(false);
}
};
const handleExport = async () => {
if (!canExport) return;
@@ -240,8 +259,40 @@ export function WritingToolbar({ editor, markdown, title, readOnly = false }: Wr
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
{/* Right side: export + model selector */}
{/* Right side: save + export + model selector */}
<div className="ml-auto flex items-center gap-1">
{onSave && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
void handleSave();
}}
disabled={!canSave}
className={cn(
"inline-flex h-7 items-center justify-center gap-1 rounded-md px-2 text-xs font-medium transition-colors",
"bg-primary text-primary-foreground hover:bg-primary/90",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
保存修改到历史记录
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-4" />
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import { getAIWritingSession } from '../api/ai-writing-sessions';
import { getAIWritingSession, updateAIWritingDraft } from '../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
import { useCanvasGraphContext } from './CanvasGraphContext';
import { useCanvasUIContext } from './CanvasUIContext';
@@ -10,6 +10,8 @@ type AIWritingHistoryContextValue = {
isHistoryMode: boolean;
openHistory: (session: AIWritingSessionSummary) => Promise<void>;
closeHistory: () => void;
/** 历史回看时把用户修改后的正文保存回该会话(持久化到后端)。 */
saveHistoryDraft: (markdown: string, title?: string) => Promise<void>;
};
const AIWritingHistoryContext = createContext<AIWritingHistoryContextValue | null>(null);
@@ -52,14 +54,25 @@ export function AIWritingHistoryProvider({ children }: { children: ReactNode })
setIsDraftEditable(true);
}, [setArtifact, setIsDraftEditable]);
const saveHistoryDraft = useCallback(
async (markdown: string, title?: string) => {
if (!historySession) return;
const updated = await updateAIWritingDraft(historySession.id, markdown, title);
// 回写本地会话,保证后续重开/重命名读到最新成稿
setHistorySession((prev) => (prev && prev.id === updated.id ? updated : prev));
},
[historySession],
);
const value = useMemo(
() => ({
historySession,
isHistoryMode: historySession !== null,
openHistory,
closeHistory,
saveHistoryDraft,
}),
[historySession, openHistory, closeHistory],
[historySession, openHistory, closeHistory, saveHistoryDraft],
);
return (