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 (
@@ -280,6 +280,8 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship
**Targeted user revision (局部修改 — 只改命中章节).** When the user submits a free-text 修改意见 on the draft-confirm card (`action == "user_revise"`), `writer_draft_node` no longer always rewrites the whole article section-by-section. Before the full-rewrite loop it tries `_targeted_user_revision` (`nodes/writer_draft.py`): `_split_draft_sections` parses the previous `current_draft.full_markdown` into `(article_title, [(section_title, body)], references_block)` (level-1 `#` title, level-2 `##` sections, trailing `## 参考文献` pulled out and kept verbatim), then `_detect_revision_scope` runs a small `llm_json` classifier (prompts `DRAFT_REVISE_SCOPE_{SYSTEM,USER}`) that maps the note to the section indices it targets ("第一段/开头/引言"→0, "结尾/结论"→last, by title/topic, etc.). **Only the hit sections are rewritten** via `_stream_revise_section` (prompt `DRAFT_REVISE_SECTION_USER`, streamed as `draft_chunk` so the user watches the edit); **every unhit section is preserved byte-for-byte** and emitted to the stream as one full-text `draft_chunk` + `is_section_done` so the frontend's `streamingDraftSections` rebuilds the complete article (it was reset to `[]` on entering `revising`). The classifier returns `whole_article=true` (整体性意见 like 「整体语气更正式」/「全文都改」, or any structure-changing ask) → `_detect_revision_scope` yields `None` → `_targeted_user_revision` returns `None` → the node **falls back to the existing whole-article rewrite** (which still runs `_revise_outline_with_notes` for title/structure changes). Also falls back when there is no prior draft, an empty note, or scope detection errors. Editor-review revisions (`accept_review`) are unaffected — they keep the holistic rewrite. Reassembly preserves the article title + section structure and re-appends the original references block; word count is computed on the body (excluding references), matching the main path. Tests: `tests/test_ai_writing_targeted_revision.py`.
**History draft editing (历史回看可编辑成稿).** The right-side draft in 历史回看 is no longer read-only — the user can edit it like the live 写作完成态 (Tiptap + 目录 + 格式工具栏 + AI 润色) and **persist** the change back to the session. `PUT /api/ai-writing/sessions/{id}/draft` `{draft_markdown, draft_title?}` (owner-checked → 404 otherwise; `draft_title` omitted ⇒ keep原标题) updates the row via the existing `AIWritingSessionRepository.update`. Frontend: `AIWritingDraftPanel` now passes `readOnly={false}` + an `onSaveDraft` handler (only in history mode) → `TextArtifactRenderer` → `WritingToolbar` 「保存」button (loading + toast); the handler is `AIWritingHistoryContext.saveHistoryDraft(markdown, title?)` → `updateAIWritingDraft` (`api/ai-writing-sessions.ts`), which write-backs the returned summary to `historySession`. Edits to the **live** completed draft stay ephemeral (export-only) as before — only history mode shows 保存. Tests: `tests/test_ai_writing_draft_update.py`.
**Background suspend (后台挂起).** A writing session can be handed off to the server to auto-drive the remaining flow to finalize, surviving page leave / refresh / account switch. `POST /api/ai-writing/sessions/{id}/background` (owner-checked, idempotent) starts `AIWritingJobExecutor` (`app/gateway/ai_writing_job_executor.py`), wired in `deps.py` as `app.state.ai_writing_job_executor`. The executor **drives the same `ai_writing` graph in-process** — it builds `make_ai_writing_graph()`, attaches the shared `app.state.checkpointer` (the very saver the runtime worker uses, so it continues the real thread state the frontend already wrote), sets the owning user via `set_current_user`, and loops `aget_state` → `ainvoke(Command(resume=…))` until `status==done`. **Auto-action per interrupt**: material/outline confirm → `confirm`; section_help → every blocked section `loose` (continue with general knowledge, never re-loop to research); draft_confirm → `to_editor` until reviewed, then `finalize` (on pass, or when `revision_count >= max_revisions`); review_confirm → `accept_review` (revise & re-review until pass or budget exhausted, then forced finalize). To avoid racing a still-running runtime worker (suspend mid-node), the driver **waits for an actionable state** (`_wait_for_actionable`): it only acts when the graph is at an interrupt / done, or when the checkpoint stops advancing (no worker → abandoned/restart). No new table — the graph checkpoint + `ai_writing_sessions` already hold all state; `status="background"` marks 挂起中 (surfaced in the history dropdown), nodes write `draft`/`review_result`/`status=done` as usual, so the user reopens the finished article from history. Per-user concurrency cap `AI_WRITING_BACKGROUND_SLOTS_PER_USER` (default 2, same SQLite-lock rationale as the roundtable executor). Startup `reconcile` (in `deps.py`) relaunches drivers for sessions left `background` after a restart (`AIWritingSessionRepository.list_by_status`). **Progress flowchart popup**: `GET /api/ai-writing/sessions/{id}/progress` (owner-checked) reads the graph checkpoint via `executor.get_progress` and derives a canonical pipeline `stage` key (`intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done`, from the pending interrupt's `pause_point` or `snapshot.next` node) + `note` (素材不足求助 / 编辑打回) + review progress + `isRunning`. Frontend: 「后台挂起」 button + auto-suspend on route unmount / `beforeunload` (`AIWritingPanel.tsx`, `suspendAIWritingToBackground[Keepalive]` in `api/ai-writing-sessions.ts`); 挂起中 badge in `AIWritingHistoryDropdown.tsx`, and **clicking a 挂起中 history record opens a flowchart dialog** (`AIWritingFlowDialog.tsx` polls `getAIWritingProgress` every 2.5s, renders the fixed vertical pipeline `AIWritingFlowChart.tsx` lit by the live `stage`) so the user sees how far the background run has gotten without reopening/taking over the session. **Transcript consistency**: the rich history timeline (`AIWritingHistoryView` → `AIWritingTimeline`) replays `transcript = {progressEvents, completedInterventions}`, normally assembled+saved client-side. A background run has no client, so the executor rebuilds it at the end (`_save_transcript`/`_build_transcript_from_checkpoint`) **entirely from the final checkpoint** — both `progressEvents` and `completedInterventions` are derived from the same set of `resumed` events (every pause-resume emits exactly one), so they are always 1:1 aligned. For each `resumed`: classify its message → `(pausePoint, action)` (`_classify_resumed`, matched against `graph.py`'s resume messages), insert an `await_user` (with the pause prompt) before it, and build a **rich** intervention card enriched from final state (material_confirm→`materials`, outline_confirm→`outline`, draft_confirm→`draftTitle`/`draftWordCount`/`reviewResult`, review_confirm→`reviewResult`); `materials_ready` is inserted before the first material-confirm. (Earlier code merged the frontend's pre-suspend `completedInterventions` with auto ones and aligned by index — but the frontend's last save can predate its most recent decision, so the counts drifted and cards rendered off-by-one with empty data; deriving everything from the checkpoint root-fixes both.) Raw (snake_case) events **and** intervention data are normalized on load by the **exported** `normalizeProgressDict` / `normalize{Material,Outline,ReviewResult}` (idempotent on already-camelCase live transcripts — one normalize path for both sources), so reopening a background-completed session shows the same cards as a frontend-driven one. Tests: `tests/test_ai_writing_background.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
@@ -33,6 +33,13 @@ class RenameSessionRequest(BaseModel):
title: str
class UpdateDraftRequest(BaseModel):
# 历史回看时用户在右侧编辑器里修改后的正文 Markdown
draft_markdown: str
# 可选:标题一并更新(用户改了文章标题时)
draft_title: Optional[str] = None
class AdminCleanupRequest(BaseModel):
"""手动清理接口的可选覆盖参数。
@@ -195,6 +202,28 @@ async def rename_session(session_id: str, body: RenameSessionRequest, request: R
return updated
@router.put("/sessions/{session_id}/draft", response_model=SessionResponse)
async def update_session_draft(session_id: str, body: UpdateDraftRequest, 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)
# draft_title 缺省(None)时不动原标题,传了才更新
extra = {} if body.draft_title is None else {"draft_title": body.draft_title}
updated = await repo.update(
session_id,
user_id=user_id,
draft_markdown=body.draft_markdown,
**extra,
)
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):
"""删除历史会话。"""