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:
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):
|
||||
"""删除历史会话。"""
|
||||
|
||||
Reference in new issue
Block a user