From c11307aa671798be0c6dc906bdbaeb7fee74a1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 27 Jun 2026 13:12:47 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ai=E5=86=99=E4=BD=9C):=20=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E5=8F=AF=E7=BB=A7=E7=BB=AD=E6=9C=AA?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=9A=84=E4=BC=9A=E8=AF=9D(=E4=BB=8Echeckpoi?= =?UTF-8?q?nt=E7=BB=AD=E8=B7=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史列表里 in_progress/draft_ready/review_ready 记录加绿色▶️「继续未完成」按钮 (点标题亦可);done/error/background 不可继续。多 worker 安全——与4个确认卡同机制, 全基于共享 checkpoint(gateway GET /threads/{id}/state + /history 读,任意 worker)。 链路:continueWriting(sessionId) → closeHistory + 设活跃 sessionId → useStream fetchStateHistory 拉该 thread checkpoint 进 stream.values → host rehydrate effect (continueTargetRef 守卫,每 thread 一次)用 buildRehydrateSnapshot 重建已完成阶段; status=awaiting_* → 显示确认卡等用户操作,否则 submit({command:{}}) 从 checkpoint 续跑当前阶段。重新加回 buildRehydrateSnapshot + rehydrate action。 入口在侧栏 WritingHistoryItem。刷新=重置不受影响(这是显式点击,非自动重连)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ai-writing/ResearchSidebar.tsx | 42 ++++++++++--- .../open-canvas/contexts/AIWritingContext.tsx | 63 ++++++++++++++++++- .../open-canvas/hooks/useAIWritingStream.ts | 40 ++++++++++++ 3 files changed, 135 insertions(+), 10 deletions(-) diff --git a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx index 73627792..f43106b7 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx @@ -12,6 +12,7 @@ import { Newspaper, PanelLeftClose, Pencil, + Play, Plus, RefreshCw, Search, @@ -109,12 +110,17 @@ const WRITING_STATUS_PILL: Record void; onShowProgress?: () => void; + /** 继续这条未完成的会话(从 checkpoint 续跑当前阶段 / 显示确认卡)。 */ + onContinue?: () => void; onDelete: () => void; onRename: (title: string) => void; }) { @@ -129,8 +137,10 @@ function WritingHistoryItem({ const [value, setValue] = useState(session.title); const inputRef = useRef(null); const isBackground = session.status === "background"; - // 「挂起中」点击看进度(流程图弹窗),其余打开历史回看。 - const open = isBackground && onShowProgress ? onShowProgress : onSelect; + const canContinue = !!onContinue && RESUMABLE_STATUSES.has(session.status); + // 行/标题主点击:未完成→继续续跑;挂起中→看进度弹窗;其余(完成/出错)→只读历史回看。 + const primary = isBackground && onShowProgress ? onShowProgress : (canContinue ? onContinue! : onSelect); + const primaryTitle = isBackground ? "查看进度" : canContinue ? "继续未完成" : "打开历史"; const pill = WRITING_STATUS_PILL[session.status] ?? { label: session.status, className: "text-slate-400" }; useEffect(() => { @@ -168,9 +178,9 @@ function WritingHistoryItem({ ) : ( + )} +
+
+ {flatAgents.length === 0 ? ( +

+ 暂无智能体 +

+ ) : ( +
+ {flatAgents.map((agent) => + renderAgentCard(agent, "configured"), + )} +
+ )} +
+
+ ) : ( -
- {groups.map((group) => { - const isCollapsed = collapsed.has(group.key); - return ( -
- - {!isCollapsed && ( -
- {group.agents.map((agent) => { - const href = `${routeBase}/agents/${agent.id}/chats/new`; - const isActive = agent.id === activeAgentId; - const { from, to } = agentColor(agent.id, isDark); - return ( - +

+ 智能体类型 +

+
+ {groups.length === 0 ? ( +

+ 暂无智能体 +

+ ) : ( +
+ {groups.map((group) => { + const isCollapsed = collapsed.has(group.key); + return ( +
+ + {!isCollapsed && ( +
+ {group.agents.map((agent) => + renderAgentCard(agent, group.key), )} - - ); - })} -
- )} +
+ )} +
+ ); + })}
- ); - })} -
+ )} +
+ )}
@@ -286,7 +395,8 @@ export function AgentModuleSidebar({
{moduleThreads.map((thread) => { const href = pathOfThread(thread, undefined, routeBase); - const isActive = href === pathname; + const isActive = + thread.thread_id === activeThreadId || href === pathname; return ( ): CompletedIntervention { - const out: Record = { ...rec }; - out.pausePoint = rec.pausePoint ?? rec.pause_point; +const PAUSE_POINTS = new Set([ + 'material_confirm', + 'outline_confirm', + 'draft_confirm', + 'review_confirm', + 'section_help', +]); + +const PAUSE_MESSAGES: Record = { + material_confirm: '素材收集专家已完成素材搜集,请确认素材后继续。', + outline_confirm: '作家已完成大纲规划,请确认或编辑大纲后继续。', + draft_confirm: '草稿已完成,请确认或给出修改意见。', + review_confirm: '编辑认为文章需要修改,请决定下一步操作。', + section_help: '部分章节缺少足够素材支撑写作,请决定如何处理。', +}; + +interface TimelineSnapshot { + materials?: CompletedIntervention['materials']; + outline?: CompletedIntervention['outline']; + draftTitle?: string; + draftWordCount?: number; + reviewResult?: ReviewResult | null; +} + +function asPausePoint(value: unknown): CompletedIntervention['pausePoint'] | null { + const pausePoint = String(value ?? ''); + return PAUSE_POINTS.has(pausePoint as CompletedIntervention['pausePoint']) + ? (pausePoint as CompletedIntervention['pausePoint']) + : null; +} + +function normalizeCompletedIntervention(rec: Record): CompletedIntervention | null { + const pausePoint = asPausePoint(rec.pausePoint ?? rec.pause_point); + if (!pausePoint) return null; + + const out: CompletedIntervention = { + pausePoint, + action: String(rec.action ?? defaultActionForPause(pausePoint)), + }; + if (Array.isArray(rec.materials)) { out.materials = (rec.materials as Record[]).map(normalizeMaterial); } - if (rec.outline) out.outline = normalizeOutline(rec.outline); - const rr = rec.reviewResult ?? rec.review_result; - if (rr) out.reviewResult = normalizeReviewResult(rr); - if (out.draftTitle == null && rec.draft_title != null) out.draftTitle = rec.draft_title; - if (out.draftWordCount == null && rec.draft_word_count != null) out.draftWordCount = rec.draft_word_count; - if (out.userInput == null && rec.user_input != null) out.userInput = rec.user_input; - return out as unknown as CompletedIntervention; + + const outline = normalizeOutline(rec.outline); + if (outline) out.outline = outline; + + const reviewResult = normalizeReviewResult(rec.reviewResult ?? rec.review_result); + if (reviewResult) out.reviewResult = reviewResult; + + const draftTitle = rec.draftTitle ?? rec.draft_title; + if (typeof draftTitle === 'string' && draftTitle.trim()) out.draftTitle = draftTitle; + + const draftWordCount = Number(rec.draftWordCount ?? rec.draft_word_count); + if (Number.isFinite(draftWordCount) && draftWordCount > 0) { + out.draftWordCount = draftWordCount; + } + + const userInput = + rec.userInput ?? + rec.user_input ?? + rec.userQuery ?? + rec.user_query ?? + rec.outlineFeedback ?? + rec.outline_feedback ?? + rec.userRevisionNotes ?? + rec.user_revision_notes; + if (typeof userInput === 'string' && userInput.trim()) out.userInput = userInput; + + return out; } -const PAUSE_LABELS: Record = { - material_confirm: '素材确认', - outline_confirm: '大纲确认', - draft_confirm: '草稿确认', - review_confirm: '编辑意见处理', -}; +function normalizeInterventionList(records: unknown): CompletedIntervention[] { + if (!Array.isArray(records)) return []; + return records + .map((rec) => ( + rec && typeof rec === 'object' + ? normalizeCompletedIntervention(rec as Record) + : null + )) + .filter((rec): rec is CompletedIntervention => rec != null); +} -const ACTION_LABELS: Record = { - approve: '已批准', - re_search: '重新搜索', - re_outline: '重新规划', - finalize: '直接定稿', - to_editor: '提交审核', - user_revise: '手动修改', - accept_review: '接受意见', - force_finalize: '强制定稿', -}; +function defaultActionForPause(pausePoint: CompletedIntervention['pausePoint']): string { + switch (pausePoint) { + case 'draft_confirm': + return 'to_editor'; + case 'review_confirm': + return 'accept_review'; + case 'section_help': + return 'section_help'; + default: + return 'confirm'; + } +} -const PAUSE_ICONS: Record = { - material_confirm: Search, - outline_confirm: PenLine, - draft_confirm: FileText, - review_confirm: ClipboardEdit, -}; +function classifyResumeEvent(message: string): Pick | null { + const m = message || ''; + if (m.includes('确认素材')) return { pausePoint: 'material_confirm', action: 'confirm' }; + if (m.includes('匹配技能检索') || m.includes('重新检索') || m.includes('重新搜索')) { + return { pausePoint: 'material_confirm', action: 're_search' }; + } + if (m.includes('确认大纲')) return { pausePoint: 'outline_confirm', action: 'confirm' }; + if (m.includes('重新规划大纲')) return { pausePoint: 'outline_confirm', action: 're_outline' }; + if (m.includes('提交编辑审核')) return { pausePoint: 'draft_confirm', action: 'to_editor' }; + if (m.includes('强制定稿')) return { pausePoint: 'review_confirm', action: 'force_finalize' }; + if (m.includes('确认定稿')) return { pausePoint: 'draft_confirm', action: 'finalize' }; + if (m.includes('按用户意见重写')) return { pausePoint: 'draft_confirm', action: 'user_revise' }; + if (m.includes('按编辑意见重写')) return { pausePoint: 'review_confirm', action: 'accept_review' }; + if (m.includes('补充检索素材')) return { pausePoint: 'section_help', action: 'supplement' }; + if (m.includes('继续写作')) return { pausePoint: 'section_help', action: 'loose' }; + return null; +} -function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) { - const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2; - const ts = record.timestamp - ? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - : ''; +function actionFromNextResume( + pausePoint: CompletedIntervention['pausePoint'], + events: ProgressEvent[], + startIndex: number, +): string { + for (let i = startIndex; i < events.length; i += 1) { + const event = events[i]; + if (event.type === 'await_user') break; + if (event.type !== 'resumed') continue; + const classified = classifyResumeEvent(event.message); + if (classified?.pausePoint === pausePoint) return classified.action; + break; + } + return defaultActionForPause(pausePoint); +} - return ( -
-
-
- -
- {!isLast &&
} -
-
-
- - {PAUSE_LABELS[record.pausePoint] ?? record.pausePoint} - - - - {ACTION_LABELS[record.action] ?? record.action} - -
- {ts &&

{ts}

} -
-
- ); +function materialsFromEvent(event: ProgressEvent | undefined): CompletedIntervention['materials'] | undefined { + if (!event) return undefined; + if (event.materials?.length) return event.materials; + const raw = event.pauseContext?.materials; + return Array.isArray(raw) + ? raw.map((item) => normalizeMaterial(item as Record)) + : undefined; +} + +function outlineFromEvent(event: ProgressEvent | undefined): CompletedIntervention['outline'] | undefined { + if (!event) return undefined; + if (event.outline) return event.outline; + const outline = normalizeOutline(event.pauseContext?.outline); + return outline ?? undefined; +} + +function reviewFromEvent(event: ProgressEvent | undefined): ReviewResult | null | undefined { + if (!event) return undefined; + if (event.reviewResult) return event.reviewResult; + return normalizeReviewResult(event.pauseContext?.reviewResult ?? event.pauseContext?.review_result) ?? undefined; +} + +function updateSnapshot(snapshot: TimelineSnapshot, event: ProgressEvent): void { + if (event.type === 'materials_ready' && event.materials) { + snapshot.materials = event.materials; + } + if (event.type === 'outline_ready' && event.outline) { + snapshot.outline = event.outline; + } + if (event.type === 'draft_ready') { + if (event.draftTitle) snapshot.draftTitle = event.draftTitle; + if (event.draftMarkdown) snapshot.draftWordCount = countArticleWords(event.draftMarkdown); + } + if (event.type === 'review_ready' && event.reviewResult) { + snapshot.reviewResult = event.reviewResult; + } +} + +function enrichIntervention( + record: CompletedIntervention, + sourceEvent: ProgressEvent | undefined, + snapshot: TimelineSnapshot, + sessionDraftTitle: string | null, + sessionDraftMarkdown: string | null, + sessionReviewResult: ReviewResult | null, +): CompletedIntervention { + const enriched: CompletedIntervention = { ...record }; + + if (record.pausePoint === 'material_confirm') { + enriched.materials = record.materials?.length + ? record.materials + : (materialsFromEvent(sourceEvent) ?? snapshot.materials ?? []); + } + + if (record.pausePoint === 'outline_confirm') { + enriched.outline = record.outline ?? outlineFromEvent(sourceEvent) ?? snapshot.outline; + } + + if (record.pausePoint === 'draft_confirm') { + enriched.draftTitle = + record.draftTitle ?? + sourceEvent?.draftTitle ?? + snapshot.draftTitle ?? + sessionDraftTitle ?? + undefined; + enriched.draftWordCount = + record.draftWordCount ?? + snapshot.draftWordCount ?? + (sessionDraftMarkdown ? countArticleWords(sessionDraftMarkdown) : undefined); + enriched.reviewResult = + record.reviewResult ?? + reviewFromEvent(sourceEvent) ?? + snapshot.reviewResult ?? + sessionReviewResult ?? + undefined; + } + + if (record.pausePoint === 'review_confirm') { + enriched.reviewResult = + record.reviewResult ?? + reviewFromEvent(sourceEvent) ?? + snapshot.reviewResult ?? + sessionReviewResult ?? + undefined; + } + + return enriched; +} + +function synthesizeInterventions( + events: ProgressEvent[], + sessionDraftTitle: string | null, + sessionDraftMarkdown: string | null, + sessionReviewResult: ReviewResult | null, +): CompletedIntervention[] { + const records: CompletedIntervention[] = []; + const snapshot: TimelineSnapshot = { + draftTitle: sessionDraftTitle ?? undefined, + draftWordCount: sessionDraftMarkdown ? countArticleWords(sessionDraftMarkdown) : undefined, + reviewResult: sessionReviewResult, + }; + const hasAwaitUser = events.some((event) => event.type === 'await_user'); + + for (let i = 0; i < events.length; i += 1) { + const event = events[i]; + + if (event.type === 'await_user') { + const pausePoint = asPausePoint(event.pausePoint); + if (pausePoint) { + const record: CompletedIntervention = { + pausePoint, + action: actionFromNextResume(pausePoint, events, i + 1), + }; + records.push( + enrichIntervention( + record, + event, + snapshot, + sessionDraftTitle, + sessionDraftMarkdown, + sessionReviewResult, + ), + ); + } + } else if (!hasAwaitUser && event.type === 'resumed') { + const classified = classifyResumeEvent(event.message); + if (classified) { + records.push( + enrichIntervention( + classified, + event, + snapshot, + sessionDraftTitle, + sessionDraftMarkdown, + sessionReviewResult, + ), + ); + } + } + + updateSnapshot(snapshot, event); + } + + return records; +} + +function mergeIntervention(primary: CompletedIntervention, fallback: CompletedIntervention): CompletedIntervention { + const defaultAction = defaultActionForPause(primary.pausePoint); + const action = primary.action === defaultAction && fallback.action + ? fallback.action + : (primary.action || fallback.action); + + return { + pausePoint: primary.pausePoint, + action, + userInput: primary.userInput ?? fallback.userInput, + materials: primary.materials?.length ? primary.materials : fallback.materials, + outline: primary.outline ?? fallback.outline, + draftTitle: primary.draftTitle ?? fallback.draftTitle, + draftWordCount: primary.draftWordCount ?? fallback.draftWordCount, + reviewResult: primary.reviewResult ?? fallback.reviewResult, + }; +} + +function mergeInterventionLists( + synthesized: CompletedIntervention[], + transcriptRecords: CompletedIntervention[], + sessionRecords: CompletedIntervention[], +): CompletedIntervention[] { + const max = Math.max(synthesized.length, transcriptRecords.length, sessionRecords.length); + const merged: CompletedIntervention[] = []; + + for (let i = 0; i < max; i += 1) { + const anchor = synthesized[i] ?? transcriptRecords[i] ?? sessionRecords[i]; + if (!anchor) continue; + + let record = anchor; + const candidates = [transcriptRecords[i], sessionRecords[i]]; + for (const candidate of candidates) { + if (!candidate || candidate.pausePoint !== record.pausePoint) continue; + record = mergeIntervention(record, candidate); + } + merged.push(record); + } + + return merged; +} + +function eventFromIntervention(record: CompletedIntervention, sessionId: string): ProgressEvent { + return { + type: 'await_user', + sessionId, + message: PAUSE_MESSAGES[record.pausePoint], + pausePoint: record.pausePoint, + materials: record.materials, + outline: record.outline, + reviewResult: record.reviewResult, + draftTitle: record.draftTitle, + }; +} + +function ensureInterventionAnchors( + events: ProgressEvent[], + records: CompletedIntervention[], + sessionId: string, +): ProgressEvent[] { + if (events.some((event) => event.type === 'await_user')) return events; + if (records.length === 0) return events; + if (events.length === 0) return records.map((record) => eventFromIntervention(record, sessionId)); + + const nextEvents: ProgressEvent[] = []; + let recordIndex = 0; + for (const event of events) { + if (event.type === 'resumed' && recordIndex < records.length) { + nextEvents.push(eventFromIntervention(records[recordIndex], sessionId)); + recordIndex += 1; + } + nextEvents.push(event); + } + while (recordIndex < records.length) { + nextEvents.push(eventFromIntervention(records[recordIndex], sessionId)); + recordIndex += 1; + } + return nextEvents; } interface AIWritingHistoryViewProps { @@ -90,26 +372,42 @@ interface AIWritingHistoryViewProps { } export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) { - const interventions = session.completed_interventions ?? []; const transcript = session.transcript; - // 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致 - const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0; - // 归一化 transcript 事件:后台挂起跑出的事件是 raw(snake_case),前端实时存的是 - // camelCase——统一过一遍 normalizeProgressDict(幂等),两种来源回显都正确一致。 + const sessionReviewResult = useMemo( + () => normalizeReviewResult(session.review_result), + [session.review_result], + ); const normalizedEvents = useMemo( () => (transcript?.progressEvents ?? []).map( - (e) => normalizeProgressDict(e as unknown as Record, session.id), + (event) => normalizeProgressDict(event as unknown as Record, session.id), ), [transcript, session.id], ); - // 干预记录同样归一化(后台重建的素材/大纲/审核数据为 snake_case;前端实时记录已是 - // camelCase,归一化幂等),保证素材列表/大纲/编辑审核卡片正确渲染。 - const normalizedInterventions = useMemo( - () => (transcript?.completedInterventions ?? []).map( - (rec) => normalizeCompletedIntervention(rec as unknown as Record), - ), + const transcriptInterventions = useMemo( + () => normalizeInterventionList(transcript?.completedInterventions), [transcript], ); + const sessionInterventions = useMemo( + () => normalizeInterventionList(session.completed_interventions), + [session.completed_interventions], + ); + const synthesizedInterventions = useMemo( + () => synthesizeInterventions( + normalizedEvents, + session.draft_title, + session.draft_markdown, + sessionReviewResult, + ), + [normalizedEvents, session.draft_title, session.draft_markdown, sessionReviewResult], + ); + const completedInterventions = useMemo( + () => mergeInterventionLists(synthesizedInterventions, transcriptInterventions, sessionInterventions), + [synthesizedInterventions, transcriptInterventions, sessionInterventions], + ); + const timelineEvents = useMemo( + () => ensureInterventionAnchors(normalizedEvents, completedInterventions, session.id), + [normalizedEvents, completedInterventions, session.id], + ); const createdAt = session.created_at ? new Date(session.created_at).toLocaleString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric', @@ -119,7 +417,6 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) { return (
- {/* 元信息 */}

{session.draft_title ?? session.title}

@@ -134,39 +431,15 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
- {hasTranscript ? ( - /* 完整对话时间线回显 */ + {timelineEvents.length > 0 ? ( ) : ( - /* 旧会话无时间线快照时,回退到精简阶段列表 */ -
- {interventions.length > 0 ? ( -
-

写作阶段

-
- {interventions.map((rec, i) => ( - - ))} -
-
- ) : ( -
无阶段记录
- )} - - {session.status === 'done' && ( -
- - 写作完成 -
- )} +
+ 暂无阶段记录
)}
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 2a87e7d6..684d341d 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -32,6 +32,7 @@ import { * 已完成阶段不重来;见 runtime/runs/worker.py)。刷新页面 = 重置(不自动重连)。 */ const ENABLE_PAUSE_RESUME_UI = true; +const RESUMABLE_HISTORY_STATUSES = new Set(['in_progress', 'draft_ready', 'review_ready']); import { countArticleWords } from '../../utils/wordCount'; import { AIWritingForm } from './AIWritingForm'; import { AIWritingTimeline } from './AIWritingTimeline'; @@ -80,7 +81,7 @@ export function AIWritingPanel({ } = {}) { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); + const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset } = useAIWriting(); const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory(); const writingForm = useWritingFormOptional(); const { sessions, loading: sessionsLoading, refresh, rename, remove, markBackground, clearBackground } = useAIWritingSessions(); @@ -198,6 +199,15 @@ export function AIWritingPanel({ reset(); }, [closeHistory, reset]); + const canRestoreHistory = + isHistoryMode && !!historySession && RESUMABLE_HISTORY_STATUSES.has(historySession.status); + + const handleRestoreHistory = useCallback(() => { + if (!historySession || !RESUMABLE_HISTORY_STATUSES.has(historySession.status)) return; + closeHistory(); + continueWriting(historySession.id); + }, [historySession, closeHistory, continueWriting]); + // ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ── // 与「多智能体会商」一致:点击后**立刻**回到初始页面,挂起请求 fire-and-forget; // 历史下拉靠乐观标记马上显示「挂起中」,不等后端确认。 @@ -307,19 +317,33 @@ export function AIWritingPanel({ )} {/* 头部 */}
- + {canRestoreHistory && ( + )} - onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }} - title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined} - > - {isHistoryMode - ? (historySession?.draft_title ?? historySession?.title ?? '写作历史') - : 'AI 智能写作'} - + { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }} + title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined} + > + {isHistoryMode + ? (historySession?.draft_title ?? historySession?.title ?? '写作历史') + : 'AI 智能写作'} + +
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */} - )} {/* Tone sub-list */} - {isTonesOpen && ( + {isTone && toneOpen && (
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
+ + {/* ── Imitate dialog:弹窗内多行输入范文,回车 / 发送即仿写 ───────────── */} + {imitateDialogOpen && ( +
+
setImitateDialogOpen(false)} + /> +
e.stopPropagation()} + > + {/* Header */} +
+ + + 仿写 + + +
+ + {/* Body */} +
+