功能迁移,页面整体样式修改
This commit is contained in:
1 parent
04664c8d42
commit
bfd75da308
41 files changed
+2232
-1920
No files matched your search
@@ -32,6 +32,34 @@ export interface AIWritingTranscript {
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
|
||||
|
||||
// ── admin 调试用 —— 阶段 7 删冗余时一并移除 ───────────────────────────────────
|
||||
export interface AIWritingCleanupResult {
|
||||
deleted_db: number;
|
||||
deleted_checkpoints: number;
|
||||
skipped_active: number;
|
||||
elapsed_seconds: number;
|
||||
params: { retention_days: number; only_finished: boolean; batch_size: number };
|
||||
}
|
||||
|
||||
/** 仅 admin 可用:触发一次性会话清理(保留 ai-writing-优化.md 迁移期临时调试入口)。 */
|
||||
export async function adminCleanupAIWritingSessions(body: {
|
||||
retention_days?: number;
|
||||
only_finished?: boolean;
|
||||
batch_size?: number;
|
||||
} = {}): Promise<AIWritingCleanupResult> {
|
||||
const resp = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/admin/cleanup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
throw new Error(`清理失败 ${resp.status}: ${text || resp.statusText}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
|
||||
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
ProgressEvent,
|
||||
ProgressEventType,
|
||||
UserInterventionPayload,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing`;
|
||||
|
||||
/** 启动写作流程,返回 session_id */
|
||||
export async function startWritingSession(req: AIWritingRequest): Promise<string> {
|
||||
const resp = await apiFetch(`${BASE()}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_intent: req.userIntent,
|
||||
article_type: req.articleType,
|
||||
target_audience: req.targetAudience,
|
||||
word_count_target: req.wordCountTarget,
|
||||
model_name: req.modelName ?? '',
|
||||
keyword_count: req.keywordCount,
|
||||
writing_mode: req.writingMode,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.session_id;
|
||||
}
|
||||
|
||||
const SSE_EVENT_TYPES: ProgressEventType[] = [
|
||||
'connected', 'step_start', 'step_done', 'research_progress',
|
||||
'materials_ready', 'outline_chunk', 'outline_ready',
|
||||
'draft_chunk', 'draft_ready',
|
||||
'review_chunk', 'review_item_done', 'review_ready',
|
||||
'await_user', 'resumed', 'notice', 'done', 'error',
|
||||
];
|
||||
|
||||
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
|
||||
|
||||
function normalizeMaterial(m: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...m,
|
||||
relevanceScore: m.relevance_score ?? m.relevanceScore ?? 0,
|
||||
publishedAt: m.published_at ?? m.publishedAt ?? '',
|
||||
sourceType: m.source_type ?? m.sourceType ?? 'internet',
|
||||
recUuid: m.recUuid ?? m.rec_uuid ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutlineSection(s: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...s,
|
||||
sectionTitle: s.section_title ?? s.sectionTitle ?? '',
|
||||
keyPoints: s.key_points ?? s.keyPoints ?? [],
|
||||
materialIds: s.material_ids ?? s.materialIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutline(o: unknown): unknown {
|
||||
if (!o || typeof o !== 'object') return o;
|
||||
const obj = o as Record<string, unknown>;
|
||||
const sections = Array.isArray(obj.sections)
|
||||
? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record<string, unknown>))
|
||||
: [];
|
||||
return { ...obj, sections };
|
||||
}
|
||||
|
||||
function normalizeReviewIssue(i: Record<string, unknown>): Record<string, unknown> {
|
||||
return { ...i };
|
||||
}
|
||||
|
||||
function normalizeReviewResult(r: unknown): unknown {
|
||||
if (!r || typeof r !== 'object') return r;
|
||||
const obj = r as Record<string, unknown>;
|
||||
return {
|
||||
...obj,
|
||||
factScore: obj.fact_score ?? obj.factScore ?? 0,
|
||||
logicScore: obj.logic_score ?? obj.logicScore ?? 0,
|
||||
languageScore: obj.language_score ?? obj.languageScore ?? 0,
|
||||
overallScore: obj.overall_score ?? obj.overallScore ?? 0,
|
||||
passThreshold: obj.pass_threshold ?? obj.passThreshold ?? 75,
|
||||
revisionNotes: Array.isArray(obj.revision_notes)
|
||||
? obj.revision_notes.map((x: unknown) => normalizeReviewIssue(x as Record<string, unknown>))
|
||||
: Array.isArray(obj.revisionNotes)
|
||||
? obj.revisionNotes
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent> {
|
||||
const out: Record<string, unknown> = { ...parsed };
|
||||
|
||||
// 通用字段
|
||||
if ('agent_name' in out) { out.agentName = out.agent_name; }
|
||||
if ('pause_point' in out) { out.pausePoint = out.pause_point; }
|
||||
|
||||
// materials 数组(materials_ready 和 await_user 里都可能有)
|
||||
if (Array.isArray(out.materials)) {
|
||||
out.materials = out.materials.map((m: unknown) =>
|
||||
normalizeMaterial(m as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
// outline
|
||||
if (out.outline) out.outline = normalizeOutline(out.outline);
|
||||
|
||||
// blocked_sections(section_help await_user)
|
||||
if (Array.isArray(out.blocked_sections)) {
|
||||
out.blockedSections = out.blocked_sections.map((b: unknown) => {
|
||||
const o = b as Record<string, unknown>;
|
||||
return {
|
||||
sectionTitle: o.section_title ?? o.sectionTitle ?? '',
|
||||
reason: o.reason ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
|
||||
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
|
||||
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
|
||||
if ('revision_count' in out) out.revisionCount = out.revision_count;
|
||||
|
||||
// review_ready
|
||||
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
|
||||
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
|
||||
|
||||
// review_item_done (streaming per-category result from sse_queue)
|
||||
if ('category' in out) out.reviewItemCategory = out.category;
|
||||
if ('score' in out) out.reviewItemScore = out.score;
|
||||
if ('issues' in out && Array.isArray(out.issues)) {
|
||||
out.reviewItemIssues = out.issues.map((x: unknown) =>
|
||||
normalizeReviewIssue(x as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
return out as Partial<ProgressEvent>;
|
||||
}
|
||||
|
||||
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
|
||||
export function subscribeToProgress(
|
||||
sessionId: string,
|
||||
onEvent: (event: ProgressEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
|
||||
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentType = '';
|
||||
let currentData = '';
|
||||
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
currentData = line.slice(6).trim();
|
||||
} else if (line === '' && currentType && currentData) {
|
||||
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
|
||||
try {
|
||||
const parsed = JSON.parse(currentData);
|
||||
const normalized = normalizeEvent(parsed);
|
||||
onEvent({
|
||||
type: currentType as ProgressEventType,
|
||||
sessionId,
|
||||
message: '',
|
||||
...normalized,
|
||||
} as ProgressEvent);
|
||||
} catch {
|
||||
// malformed JSON — skip
|
||||
}
|
||||
}
|
||||
currentType = '';
|
||||
currentData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { stopped = true; };
|
||||
}
|
||||
|
||||
/** 提交用户干预决策,恢复暂停的流程 */
|
||||
export async function resumeWriting(
|
||||
sessionId: string,
|
||||
payload: UserInterventionPayload,
|
||||
): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: payload.action,
|
||||
approved_material_ids: payload.approvedMaterialIds ?? [],
|
||||
extra_keywords: payload.extraKeywords ?? [],
|
||||
edited_outline: payload.editedOutline ?? null,
|
||||
outline_feedback: payload.outlineFeedback ?? '',
|
||||
user_revision_notes: payload.userRevisionNotes ?? '',
|
||||
override_verdict: payload.overrideVerdict ?? false,
|
||||
selected_issue_indices: payload.selectedIssueIndices ?? null,
|
||||
section_decisions: (payload.sectionDecisions ?? []).map((d) => ({
|
||||
section_title: d.sectionTitle,
|
||||
decision: d.decision,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
|
||||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -13,8 +14,9 @@ import {
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { useAIWriting } from '../../hooks/useAIWriting';
|
||||
import { useAIWriting } from '../../hooks/useAIWritingStream';
|
||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
||||
import { AIWritingForm } from './AIWritingForm';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
import { InterventionCard } from './InterventionCard';
|
||||
@@ -171,6 +173,23 @@ export function AIWritingPanel() {
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
|
||||
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
||||
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
||||
const handleAdminCleanup = useCallback(async () => {
|
||||
if (!window.confirm('确认清理:retention_days=0 会清掉所有 done/error 的历史会话,正在跑的会跳过。继续?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await adminCleanupAIWritingSessions({ retention_days: 0, only_finished: true });
|
||||
toast.success(`已清理:DB ${result.deleted_db} 行,checkpoint ${result.deleted_checkpoints} 条,跳过活跃 ${result.skipped_active}`);
|
||||
// 刷新历史列表
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const showNewButton = historySession !== null || state.status !== 'idle';
|
||||
|
||||
// History view mode
|
||||
@@ -225,6 +244,16 @@ export function AIWritingPanel() {
|
||||
AI 智能写作
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={handleAdminCleanup}
|
||||
title="[admin 调试] 清理所有过期会话"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing';
|
||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||
import { countWords } from '../utils/wordCount';
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
AIWritingState,
|
||||
AIMaterial,
|
||||
AIOutline,
|
||||
CompletedIntervention,
|
||||
ProgressEvent,
|
||||
ReviewResult,
|
||||
StreamingDraftSection,
|
||||
StreamingReviewItem,
|
||||
UserInterventionPayload,
|
||||
WritingStatus,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
const INITIAL_STATE: AIWritingState = {
|
||||
sessionId: null,
|
||||
status: 'idle',
|
||||
request: null,
|
||||
isPaused: false,
|
||||
progressEvents: [],
|
||||
materials: [],
|
||||
outline: null,
|
||||
currentDraftMarkdown: '',
|
||||
currentDraftTitle: '',
|
||||
reviewResult: null,
|
||||
revisionCount: 0,
|
||||
currentPause: null,
|
||||
completedInterventions: [],
|
||||
error: null,
|
||||
streamingDraftTitle: '',
|
||||
streamingDraftSections: [],
|
||||
outlineStreamingText: '',
|
||||
outlineThinkingText: '',
|
||||
researchProgress: '',
|
||||
streamingReviewItems: [],
|
||||
revisionSummary: null,
|
||||
pendingRevisionContext: null,
|
||||
};
|
||||
|
||||
export function useAIWriting() {
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const [state, setState] = useState<AIWritingState>(INITIAL_STATE);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const unsubRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: ProgressEvent) => {
|
||||
setState((prev) => {
|
||||
const base: AIWritingState = {
|
||||
...prev,
|
||||
progressEvents: [...prev.progressEvents, event],
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case 'step_start':
|
||||
// 兜底:编辑节点完成时若没有流式事件,也切到审核态
|
||||
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
|
||||
return { ...base, status: 'reviewing' };
|
||||
}
|
||||
return base;
|
||||
|
||||
case 'step_done':
|
||||
return base;
|
||||
|
||||
case 'research_progress':
|
||||
// 素材收集专家实时进度:仅更新最新提示文案
|
||||
return { ...base, researchProgress: event.message ?? '' };
|
||||
|
||||
case 'resumed':
|
||||
return { ...base, currentPause: null };
|
||||
|
||||
case 'materials_ready':
|
||||
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹);进度文案已完成可清空
|
||||
return {
|
||||
...base,
|
||||
materials: (event.materials ?? []) as AIMaterial[],
|
||||
researchProgress: '',
|
||||
};
|
||||
|
||||
case 'outline_chunk': {
|
||||
const chunkText = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
return {
|
||||
...prev,
|
||||
status: 'writing_outline',
|
||||
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + chunkText,
|
||||
outlineThinkingText: isThinking ? prev.outlineThinkingText + chunkText : prev.outlineThinkingText,
|
||||
};
|
||||
}
|
||||
|
||||
case 'outline_ready':
|
||||
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本
|
||||
return {
|
||||
...base,
|
||||
outline: (event.outline ?? null) as AIOutline | null,
|
||||
outlineStreamingText: '',
|
||||
outlineThinkingText: '',
|
||||
};
|
||||
|
||||
case 'draft_chunk': {
|
||||
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
|
||||
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
|
||||
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
|
||||
const existing = sections.find((s) => s.sectionIndex === idx);
|
||||
if (existing) {
|
||||
if (isThinking) existing.thinkingText += text;
|
||||
else existing.text += text;
|
||||
existing.isDone = done;
|
||||
} else {
|
||||
sections.push({
|
||||
sectionIndex: idx,
|
||||
sectionTitle: title,
|
||||
text: isThinking ? '' : text,
|
||||
thinkingText: isThinking ? text : '',
|
||||
isDone: done,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
status: 'writing_draft',
|
||||
streamingDraftSections: sections,
|
||||
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
|
||||
};
|
||||
}
|
||||
|
||||
case 'review_chunk': {
|
||||
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
// 首个 chunk 且非审核态 → 新一轮审核,清空上一轮残留
|
||||
const isNewCycle = prev.status !== 'reviewing';
|
||||
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
|
||||
let item = items.find((it) => it.category === category);
|
||||
if (!item) {
|
||||
item = {
|
||||
category, isDone: false,
|
||||
streamingText: '', thinkingText: '',
|
||||
score: 0, issues: [],
|
||||
};
|
||||
items.push(item);
|
||||
}
|
||||
if (isThinking) item.thinkingText += text;
|
||||
else item.streamingText += text;
|
||||
return { ...prev, status: 'reviewing', streamingReviewItems: items };
|
||||
}
|
||||
|
||||
case 'review_item_done': {
|
||||
const category = event.reviewItemCategory ?? 'fact';
|
||||
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
|
||||
const item = items.find((it) => it.category === category);
|
||||
if (item) {
|
||||
item.isDone = true;
|
||||
item.score = event.reviewItemScore ?? 80;
|
||||
item.issues = event.reviewItemIssues ?? [];
|
||||
} else {
|
||||
// 兜底:没收到任何 chunk 直接来 done
|
||||
items.push({
|
||||
category, isDone: true,
|
||||
streamingText: '', thinkingText: '',
|
||||
score: event.reviewItemScore ?? 80,
|
||||
issues: event.reviewItemIssues ?? [],
|
||||
});
|
||||
}
|
||||
return { ...prev, status: 'reviewing', streamingReviewItems: items };
|
||||
}
|
||||
|
||||
case 'draft_ready': {
|
||||
const newMarkdown = event.draftMarkdown ?? '';
|
||||
const newWordCount = countWords(newMarkdown);
|
||||
const summary = prev.pendingRevisionContext
|
||||
? {
|
||||
revisedIssues: prev.pendingRevisionContext.submittedIssues,
|
||||
skippedIssues: prev.pendingRevisionContext.skippedIssues,
|
||||
oldWordCount: prev.pendingRevisionContext.oldWordCount,
|
||||
newWordCount,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
...base,
|
||||
currentDraftMarkdown: newMarkdown,
|
||||
currentDraftTitle: event.draftTitle ?? '',
|
||||
revisionCount: event.revisionCount ?? 0,
|
||||
streamingDraftSections: [],
|
||||
streamingDraftTitle: '',
|
||||
revisionSummary: summary,
|
||||
pendingRevisionContext: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'review_ready':
|
||||
return {
|
||||
...base,
|
||||
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
|
||||
};
|
||||
|
||||
case 'await_user': {
|
||||
const pp = event.pausePoint;
|
||||
// Skip duplicate on SSE reconnect: backend re-injects the saved await_user event
|
||||
// when queue is empty. If we already have this pause set, nothing needs to change.
|
||||
if (prev.currentPause?.pausePoint === pp) return prev;
|
||||
const pauseStatus = pp
|
||||
? (`awaiting_${pp}` as AIWritingState['status'])
|
||||
: prev.status;
|
||||
// 若 await_user 里携带了素材/大纲/审核数据,一并更新(兜底 materials_ready 丢失的情况)
|
||||
const extra: Partial<AIWritingState> = {};
|
||||
if (pp === 'material_confirm' && event.materials?.length) {
|
||||
extra.materials = event.materials as AIMaterial[];
|
||||
}
|
||||
if (pp === 'outline_confirm' && event.outline) {
|
||||
extra.outline = event.outline as AIOutline;
|
||||
}
|
||||
return { ...base, ...extra, status: pauseStatus, currentPause: event };
|
||||
}
|
||||
|
||||
case 'done':
|
||||
return { ...base, status: 'done', currentPause: null };
|
||||
|
||||
case 'error': {
|
||||
// error 事件携带 title(如「模型错误」)+ detail(真实原因),拼成底部状态栏文案
|
||||
const msg = event.detail
|
||||
? `${event.title ?? '出错了'}:${event.detail}`
|
||||
: (event.message ?? '未知错误');
|
||||
return { ...base, status: 'error', error: msg };
|
||||
}
|
||||
|
||||
// notice:非致命告警(ES 波动 / 模型兜底),仅追加到时间线,不改变写作状态
|
||||
case 'notice':
|
||||
return base;
|
||||
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
});
|
||||
|
||||
// 草稿实时同步到右侧 Artifact 面板
|
||||
if (event.type === 'draft_ready' && event.draftMarkdown) {
|
||||
setArtifact({
|
||||
currentIndex: 0,
|
||||
contents: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'text',
|
||||
title: event.draftTitle ?? '草稿',
|
||||
fullMarkdown: event.draftMarkdown,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[setArtifact],
|
||||
);
|
||||
|
||||
const startWriting = useCallback(
|
||||
async (request: AIWritingRequest) => {
|
||||
if (
|
||||
state.status !== 'idle' &&
|
||||
state.status !== 'done' &&
|
||||
state.status !== 'error'
|
||||
)
|
||||
return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setState({ ...INITIAL_STATE, status: 'researching', request });
|
||||
|
||||
try {
|
||||
const sessionId = await startWritingSession(request);
|
||||
setState((prev) => ({ ...prev, sessionId }));
|
||||
unsubRef.current = subscribeToProgress(
|
||||
sessionId,
|
||||
handleEvent,
|
||||
abortRef.current.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: String(err),
|
||||
}));
|
||||
}
|
||||
},
|
||||
[state.status, handleEvent],
|
||||
);
|
||||
|
||||
const submitIntervention = useCallback(
|
||||
async (payload: UserInterventionPayload) => {
|
||||
if (!state.sessionId || !state.currentPause) return;
|
||||
|
||||
// 提交干预前始终重连 SSE:
|
||||
// - 正常流程下旧连接仍在,abort 后重建无副作用(后端通过 _session_pending_pause
|
||||
// 重发 await_user,前端 handleEvent 对同一暂停点有去重);
|
||||
// - 若旧连接已因服务重启 / 反向代理空闲超时而断开,这一步能确保后端恢复出的
|
||||
// 会话有消费者,否则 resume 后的进度事件会堆在队列里无人接收。
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
unsubRef.current = subscribeToProgress(
|
||||
state.sessionId,
|
||||
handleEvent,
|
||||
abortRef.current.signal,
|
||||
);
|
||||
if (state.isPaused) {
|
||||
setState((prev) => ({ ...prev, isPaused: false }));
|
||||
}
|
||||
|
||||
const pausePoint = state.currentPause.pausePoint;
|
||||
const record: CompletedIntervention = {
|
||||
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
||||
action: payload.action,
|
||||
};
|
||||
if (pausePoint === 'material_confirm') {
|
||||
record.materials = state.materials;
|
||||
} else if (pausePoint === 'outline_confirm') {
|
||||
record.outline = state.outline ?? undefined;
|
||||
} else if (pausePoint === 'draft_confirm') {
|
||||
record.draftTitle = state.currentDraftTitle;
|
||||
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
}
|
||||
|
||||
// 提交编辑意见修改时,记录本次选中/跳过的意见用于后续生成摘要
|
||||
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
|
||||
const isApplyingReview =
|
||||
payload.selectedIssueIndices !== undefined &&
|
||||
state.reviewResult != null;
|
||||
if (isApplyingReview && state.reviewResult) {
|
||||
const allIssues = state.reviewResult.revisionNotes;
|
||||
const selectedSet = new Set(payload.selectedIssueIndices!);
|
||||
const submittedIssues = allIssues.filter((_, i) => selectedSet.has(i));
|
||||
const skippedIssues = allIssues.filter((_, i) => !selectedSet.has(i));
|
||||
pendingRevisionContext = {
|
||||
submittedIssues,
|
||||
skippedIssues,
|
||||
oldWordCount: countWords(state.currentDraftMarkdown),
|
||||
};
|
||||
}
|
||||
|
||||
// Optimistic next-status so the correct skeleton shows immediately after
|
||||
// the user clicks confirm, without waiting for the first SSE chunk.
|
||||
let nextStatus: WritingStatus = state.status;
|
||||
if (pausePoint === 'material_confirm') {
|
||||
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
|
||||
} else if (pausePoint === 'outline_confirm') {
|
||||
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
|
||||
} else if (pausePoint === 'draft_confirm') {
|
||||
if (payload.action === 'finalize') nextStatus = 'done';
|
||||
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
|
||||
else nextStatus = 'revising'; // user_revise
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review
|
||||
} else if (pausePoint === 'section_help') {
|
||||
// 含「补充检索」→ 回检索;否则(删章节 / 通用知识续写)→ 直接回写作
|
||||
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
|
||||
? 'researching'
|
||||
: 'writing_draft';
|
||||
}
|
||||
|
||||
try {
|
||||
await resumeWritingApi(state.sessionId, payload);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentPause: null,
|
||||
status: nextStatus,
|
||||
// Clear stale streaming state so new-cycle detection in chunk handlers works correctly
|
||||
...(nextStatus === 'researching' ? { researchProgress: '' } : {}),
|
||||
...(nextStatus === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
|
||||
...(nextStatus === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
...(nextStatus === 'reviewing' ? { streamingReviewItems: [] } : {}),
|
||||
...(nextStatus === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
completedInterventions: [...prev.completedInterventions, record],
|
||||
...(pendingRevisionContext ? { pendingRevisionContext } : {}),
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((prev) => ({ ...prev, error: String(err) }));
|
||||
}
|
||||
},
|
||||
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
|
||||
);
|
||||
|
||||
const pauseWriting = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
unsubRef.current?.();
|
||||
setState((prev) => ({ ...prev, isPaused: true }));
|
||||
}, []);
|
||||
|
||||
const resumeWriting = useCallback(() => {
|
||||
if (!state.sessionId) return;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
unsubRef.current = subscribeToProgress(
|
||||
state.sessionId,
|
||||
handleEvent,
|
||||
abortRef.current.signal,
|
||||
);
|
||||
setState((prev) => ({ ...prev, isPaused: false }));
|
||||
}, [state.sessionId, handleEvent]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
unsubRef.current?.();
|
||||
setArtifact(null);
|
||||
setState(INITIAL_STATE);
|
||||
}, [setArtifact]);
|
||||
|
||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||
|
||||
// 在暂停点 / 完成 / 出错时,把完整对话时间线持久化到后端,供历史回显出与实时一致的内容
|
||||
useEffect(() => {
|
||||
const s = state.status;
|
||||
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
||||
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
|
||||
saveAIWritingTranscript(state.sessionId, {
|
||||
progressEvents: state.progressEvents,
|
||||
completedInterventions: state.completedInterventions,
|
||||
}).catch(() => {});
|
||||
// 仅在状态进入可保存阶段时触发;events/interventions 取闭包最新值,无需进 deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status, state.sessionId]);
|
||||
|
||||
return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset };
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
/**
|
||||
* AI 写作前端 hook —— LangGraph Server 路径(阶段 3)。
|
||||
*
|
||||
* 对外 API 与老 hook ``useAIWriting`` 完全一致:返回
|
||||
* ``{ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }``,
|
||||
* 所以 ``AIWritingPanel`` 只需要按 env 开关切 import,不用改任何组件。
|
||||
*
|
||||
* 底层差别(参见 ai-writing-优化.md §阶段 3):
|
||||
* - 老 hook 走 ``POST /start`` + ``GET /{id}/stream`` + ``POST /{id}/resume`` 自写 SSE
|
||||
* - 本 hook 用 ``@langchain/langgraph-sdk/react`` 的 ``useStream``,assistant=``ai_writing``,
|
||||
* thread 和 SSE 协议都交给 LangGraph Server 管。``resume`` 改成 ``Command(resume=...)``。
|
||||
*
|
||||
* 事件翻译层:
|
||||
* - LangGraph ``custom`` 通道(节点 ``get_stream_writer`` 推) →
|
||||
* ``ProgressEvent`` { type=draft_chunk/outline_chunk/research_progress/review_chunk/review_item_done }
|
||||
* - LangGraph ``updates`` 通道(节点 return 的 state delta) → 合成
|
||||
* ``materials_ready`` / ``outline_ready`` / ``draft_ready`` / ``review_ready`` /
|
||||
* ``notice`` / ``step_*`` (后端节点把这些塞进 ``progress_events`` 数组里 return)
|
||||
* - ``stream.interrupt`` → ``await_user`` 事件
|
||||
*
|
||||
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
|
||||
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useStream } from '@langchain/langgraph-sdk/react';
|
||||
|
||||
import { getAPIClient } from '@/core/api';
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||
import { countWords } from '../utils/wordCount';
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
AIWritingState,
|
||||
AIMaterial,
|
||||
AIOutline,
|
||||
BlockedSection,
|
||||
CompletedIntervention,
|
||||
ProgressEvent,
|
||||
ProgressEventType,
|
||||
ReviewIssue,
|
||||
ReviewResult,
|
||||
StreamingReviewItem,
|
||||
UserInterventionPayload,
|
||||
WritingStatus,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
// ── INITIAL_STATE 与 reducer 与老 hook 共享 ────────────────────────────────────
|
||||
|
||||
const INITIAL_STATE: AIWritingState = {
|
||||
sessionId: null,
|
||||
status: 'idle',
|
||||
request: null,
|
||||
isPaused: false,
|
||||
progressEvents: [],
|
||||
materials: [],
|
||||
outline: null,
|
||||
currentDraftMarkdown: '',
|
||||
currentDraftTitle: '',
|
||||
reviewResult: null,
|
||||
revisionCount: 0,
|
||||
currentPause: null,
|
||||
completedInterventions: [],
|
||||
error: null,
|
||||
streamingDraftTitle: '',
|
||||
streamingDraftSections: [],
|
||||
outlineStreamingText: '',
|
||||
outlineThinkingText: '',
|
||||
researchProgress: '',
|
||||
streamingReviewItems: [],
|
||||
revisionSummary: null,
|
||||
pendingRevisionContext: null,
|
||||
};
|
||||
|
||||
// reducer action:HANDLE 直接复用老 hook 的 handleEvent switch;剩下的 action 处理
|
||||
// 启动 / 干预提交 / 暂停 / 恢复 / 重置 这些"指令式"状态变更。
|
||||
type Action =
|
||||
| { type: 'handle'; event: ProgressEvent }
|
||||
| { type: 'start'; request: AIWritingRequest }
|
||||
| { type: 'set_session'; sessionId: string }
|
||||
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
|
||||
| { type: 'pause' }
|
||||
| { type: 'resume_ui' }
|
||||
| { type: 'reset' }
|
||||
| { type: 'set_error'; message: string };
|
||||
|
||||
function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
switch (action.type) {
|
||||
case 'handle':
|
||||
return handleEvent(prev, action.event);
|
||||
|
||||
case 'start':
|
||||
return { ...INITIAL_STATE, status: 'researching', request: action.request };
|
||||
|
||||
case 'set_session':
|
||||
return { ...prev, sessionId: action.sessionId };
|
||||
|
||||
case 'intervention_submitted': {
|
||||
const ns = action.nextStatus;
|
||||
return {
|
||||
...prev,
|
||||
currentPause: null,
|
||||
status: ns,
|
||||
...(ns === 'researching' ? { researchProgress: '' } : {}),
|
||||
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
|
||||
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
|
||||
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
completedInterventions: [...prev.completedInterventions, action.record],
|
||||
...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
case 'pause':
|
||||
return { ...prev, isPaused: true };
|
||||
|
||||
case 'resume_ui':
|
||||
return { ...prev, isPaused: false };
|
||||
|
||||
case 'reset':
|
||||
return INITIAL_STATE;
|
||||
|
||||
case 'set_error':
|
||||
return { ...prev, status: 'error', error: action.message };
|
||||
|
||||
default:
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
|
||||
// 与老 hook 行为对齐的事件 reducer —— 直接从 useAIWriting.ts 移植,删掉 setArtifact 副作用
|
||||
// (副作用单独提到 hook 顶层 useEffect 里处理)。
|
||||
function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState {
|
||||
const base: AIWritingState = {
|
||||
...prev,
|
||||
progressEvents: [...prev.progressEvents, event],
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case 'step_start':
|
||||
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
|
||||
return { ...base, status: 'reviewing' };
|
||||
}
|
||||
return base;
|
||||
|
||||
case 'step_done':
|
||||
return base;
|
||||
|
||||
case 'research_progress':
|
||||
return { ...base, researchProgress: event.message ?? '' };
|
||||
|
||||
case 'resumed':
|
||||
return { ...base, currentPause: null };
|
||||
|
||||
case 'materials_ready':
|
||||
return {
|
||||
...base,
|
||||
materials: (event.materials ?? []) as AIMaterial[],
|
||||
researchProgress: '',
|
||||
};
|
||||
|
||||
case 'outline_chunk': {
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
return {
|
||||
...prev,
|
||||
status: 'writing_outline',
|
||||
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + text,
|
||||
outlineThinkingText: isThinking ? prev.outlineThinkingText + text : prev.outlineThinkingText,
|
||||
};
|
||||
}
|
||||
|
||||
case 'outline_ready':
|
||||
return {
|
||||
...base,
|
||||
outline: (event.outline ?? null) as AIOutline | null,
|
||||
outlineStreamingText: '',
|
||||
outlineThinkingText: '',
|
||||
};
|
||||
|
||||
case 'draft_chunk': {
|
||||
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
|
||||
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
|
||||
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
|
||||
const existing = sections.find((s) => s.sectionIndex === idx);
|
||||
if (existing) {
|
||||
if (isThinking) existing.thinkingText += text;
|
||||
else existing.text += text;
|
||||
existing.isDone = done;
|
||||
} else {
|
||||
sections.push({
|
||||
sectionIndex: idx,
|
||||
sectionTitle: title,
|
||||
text: isThinking ? '' : text,
|
||||
thinkingText: isThinking ? text : '',
|
||||
isDone: done,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
status: 'writing_draft',
|
||||
streamingDraftSections: sections,
|
||||
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
|
||||
};
|
||||
}
|
||||
|
||||
case 'review_chunk': {
|
||||
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
const isNewCycle = prev.status !== 'reviewing';
|
||||
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
|
||||
let item = items.find((it) => it.category === category);
|
||||
if (!item) {
|
||||
item = { category, isDone: false, streamingText: '', thinkingText: '', score: 0, issues: [] };
|
||||
items.push(item);
|
||||
}
|
||||
if (isThinking) item.thinkingText += text;
|
||||
else item.streamingText += text;
|
||||
return { ...prev, status: 'reviewing', streamingReviewItems: items };
|
||||
}
|
||||
|
||||
case 'review_item_done': {
|
||||
const category = event.reviewItemCategory ?? 'fact';
|
||||
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
|
||||
const item = items.find((it) => it.category === category);
|
||||
if (item) {
|
||||
item.isDone = true;
|
||||
item.score = event.reviewItemScore ?? 80;
|
||||
item.issues = event.reviewItemIssues ?? [];
|
||||
} else {
|
||||
items.push({
|
||||
category, isDone: true, streamingText: '', thinkingText: '',
|
||||
score: event.reviewItemScore ?? 80,
|
||||
issues: event.reviewItemIssues ?? [],
|
||||
});
|
||||
}
|
||||
return { ...prev, status: 'reviewing', streamingReviewItems: items };
|
||||
}
|
||||
|
||||
case 'draft_ready': {
|
||||
const newMarkdown = event.draftMarkdown ?? '';
|
||||
const newWordCount = countWords(newMarkdown);
|
||||
const summary = prev.pendingRevisionContext
|
||||
? {
|
||||
revisedIssues: prev.pendingRevisionContext.submittedIssues,
|
||||
skippedIssues: prev.pendingRevisionContext.skippedIssues,
|
||||
oldWordCount: prev.pendingRevisionContext.oldWordCount,
|
||||
newWordCount,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
...base,
|
||||
currentDraftMarkdown: newMarkdown,
|
||||
currentDraftTitle: event.draftTitle ?? '',
|
||||
revisionCount: event.revisionCount ?? 0,
|
||||
streamingDraftSections: [],
|
||||
streamingDraftTitle: '',
|
||||
revisionSummary: summary,
|
||||
pendingRevisionContext: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'review_ready':
|
||||
return { ...base, reviewResult: (event.reviewResult ?? null) as ReviewResult | null };
|
||||
|
||||
case 'await_user': {
|
||||
const pp = event.pausePoint;
|
||||
if (prev.currentPause?.pausePoint === pp) return prev;
|
||||
const pauseStatus = pp ? (`awaiting_${pp}` as AIWritingState['status']) : prev.status;
|
||||
const extra: Partial<AIWritingState> = {};
|
||||
if (pp === 'material_confirm' && event.materials?.length) extra.materials = event.materials;
|
||||
if (pp === 'outline_confirm' && event.outline) extra.outline = event.outline;
|
||||
return { ...base, ...extra, status: pauseStatus, currentPause: event };
|
||||
}
|
||||
|
||||
case 'done':
|
||||
return { ...base, status: 'done', currentPause: null };
|
||||
|
||||
case 'error': {
|
||||
const msg = event.detail
|
||||
? `${event.title ?? '出错了'}:${event.detail}`
|
||||
: (event.message ?? '未知错误');
|
||||
return { ...base, status: 'error', error: msg };
|
||||
}
|
||||
|
||||
case 'notice':
|
||||
return base;
|
||||
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
// ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ──────
|
||||
|
||||
function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
|
||||
return {
|
||||
id: String(m.id ?? ''),
|
||||
title: String(m.title ?? ''),
|
||||
content: String(m.content ?? ''),
|
||||
source: String(m.source ?? ''),
|
||||
publishedAt: String(m.published_at ?? m.publishedAt ?? ''),
|
||||
relevanceScore: Number(m.relevance_score ?? m.relevanceScore ?? 0),
|
||||
sourceType: (m.source_type ?? m.sourceType ?? 'internet') as 'internet' | 'intranet',
|
||||
url: m.url as string | undefined,
|
||||
recUuid: (m.recUuid ?? m.rec_uuid) as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutlineSection(s: Record<string, unknown>) {
|
||||
return {
|
||||
sectionTitle: String(s.section_title ?? s.sectionTitle ?? ''),
|
||||
keyPoints: (s.key_points ?? s.keyPoints ?? []) as string[],
|
||||
materialIds: (s.material_ids ?? s.materialIds ?? []) as string[],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutline(o: unknown): AIOutline | null {
|
||||
if (!o || typeof o !== 'object') return null;
|
||||
const obj = o as Record<string, unknown>;
|
||||
return {
|
||||
title: String(obj.title ?? ''),
|
||||
sections: Array.isArray(obj.sections) ? obj.sections.map((s) => normalizeOutlineSection(s as Record<string, unknown>)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReviewIssue(i: Record<string, unknown>): ReviewIssue {
|
||||
return {
|
||||
category: (i.category as ReviewIssue['category']) ?? 'fact',
|
||||
severity: (i.severity as ReviewIssue['severity']) ?? 'minor',
|
||||
location: String(i.location ?? ''),
|
||||
issue: String(i.issue ?? ''),
|
||||
suggestion: String(i.suggestion ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReviewResult(r: unknown): ReviewResult | null {
|
||||
if (!r || typeof r !== 'object') return null;
|
||||
const o = r as Record<string, unknown>;
|
||||
return {
|
||||
verdict: (o.verdict as 'pass' | 'reject') ?? 'pass',
|
||||
factScore: Number(o.fact_score ?? o.factScore ?? 0),
|
||||
logicScore: Number(o.logic_score ?? o.logicScore ?? 0),
|
||||
languageScore: Number(o.language_score ?? o.languageScore ?? 0),
|
||||
overallScore: Number(o.overall_score ?? o.overallScore ?? 0),
|
||||
passThreshold: Number(o.pass_threshold ?? o.passThreshold ?? 75),
|
||||
revisionNotes: Array.isArray(o.revision_notes ?? o.revisionNotes)
|
||||
? ((o.revision_notes ?? o.revisionNotes) as Array<Record<string, unknown>>).map(normalizeReviewIssue)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBlockedSection(b: Record<string, unknown>): BlockedSection {
|
||||
return {
|
||||
sectionTitle: String(b.section_title ?? b.sectionTitle ?? ''),
|
||||
reason: String(b.reason ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
/** 把后端节点 ``progress_events`` 数组里的一条 dict 翻译成前端 ProgressEvent。 */
|
||||
function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: string): ProgressEvent {
|
||||
const out: Record<string, unknown> = { ...parsed, sessionId };
|
||||
|
||||
if ('agent_name' in out) out.agentName = out.agent_name;
|
||||
if ('pause_point' in out) out.pausePoint = out.pause_point;
|
||||
|
||||
if (Array.isArray(out.materials)) out.materials = (out.materials as Record<string, unknown>[]).map(normalizeMaterial);
|
||||
if (out.outline) out.outline = normalizeOutline(out.outline);
|
||||
|
||||
if (Array.isArray(out.blocked_sections)) {
|
||||
out.blockedSections = (out.blocked_sections as Record<string, unknown>[]).map(normalizeBlockedSection);
|
||||
}
|
||||
|
||||
// draft_ready: 节点 emit 的字段是 draft_markdown / draft_title
|
||||
if ('draft_markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.draft_markdown;
|
||||
if ('draft_title' in out && !out.draftTitle) out.draftTitle = out.draft_title;
|
||||
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
|
||||
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
|
||||
if ('revision_count' in out) out.revisionCount = out.revision_count;
|
||||
|
||||
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
|
||||
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
|
||||
|
||||
if ('category' in out) out.reviewItemCategory = out.category;
|
||||
if ('score' in out) out.reviewItemScore = out.score;
|
||||
if ('issues' in out && Array.isArray(out.issues)) {
|
||||
out.reviewItemIssues = (out.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
|
||||
}
|
||||
|
||||
if (!out.message) out.message = '';
|
||||
return out as unknown as ProgressEvent;
|
||||
}
|
||||
|
||||
/** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */
|
||||
function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const type = String(o.type ?? '');
|
||||
// 后端 _save_draft_to_repo 等 helper 不走 custom 通道,type 必为节点 emit 的 chunk 类型
|
||||
if (!type) return null;
|
||||
const allowed: ProgressEventType[] = [
|
||||
'research_progress', 'outline_chunk', 'draft_chunk',
|
||||
'review_chunk', 'review_item_done',
|
||||
];
|
||||
if (!allowed.includes(type as ProgressEventType)) return null;
|
||||
|
||||
// 共通:原样保留所有字段(is_thinking / text / section_index / section_title / category / score / issues)
|
||||
const merged: Record<string, unknown> = { ...o, sessionId, message: o.message ?? '' };
|
||||
// draft_chunk / outline_chunk 用 camelCase 字段名(reducer 期望)
|
||||
if ('section_index' in merged) merged.sectionIndex = merged.section_index;
|
||||
if ('section_title' in merged) merged.sectionTitle = merged.section_title;
|
||||
if ('is_section_done' in merged) merged.isSectionDone = merged.is_section_done;
|
||||
if ('category' in merged) merged.reviewItemCategory = merged.category;
|
||||
if ('score' in merged) merged.reviewItemScore = merged.score;
|
||||
if (Array.isArray(merged.issues)) {
|
||||
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
|
||||
}
|
||||
return merged as unknown as ProgressEvent;
|
||||
}
|
||||
|
||||
/** updates 通道里的节点输出 → 一组 ProgressEvent(progress_events + *_ready 合成)。 */
|
||||
function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] {
|
||||
if (!nodeOutput || typeof nodeOutput !== 'object') return [];
|
||||
const out = nodeOutput as Record<string, unknown>;
|
||||
const events: ProgressEvent[] = [];
|
||||
|
||||
// 节点 return 的 progress_events 数组(step_start / step_done / notice / draft_ready / outline_ready / review_ready / resumed / error)
|
||||
if (Array.isArray(out.progress_events)) {
|
||||
for (const e of out.progress_events as Record<string, unknown>[]) {
|
||||
events.push(normalizeProgressDict(e, sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
// ``material_package`` 出现 → materials_ready(节点 progress_events 里不带)
|
||||
if (out.material_package && typeof out.material_package === 'object') {
|
||||
const pkg = out.material_package as Record<string, unknown>;
|
||||
events.push({
|
||||
type: 'materials_ready',
|
||||
sessionId,
|
||||
message: '',
|
||||
materials: Array.isArray(pkg.materials) ? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [],
|
||||
keywords: (pkg.keywords ?? []) as string[],
|
||||
materialSummary: (pkg.summary as string) ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// ``current_outline`` 出现且 progress_events 里没 outline_ready → 合成一条
|
||||
if (out.current_outline && !events.some((e) => e.type === 'outline_ready')) {
|
||||
const outline = normalizeOutline(out.current_outline);
|
||||
if (outline) events.push({ type: 'outline_ready', sessionId, message: '', outline });
|
||||
}
|
||||
|
||||
// ``review_result`` 出现且 progress_events 里没 review_ready → 合成一条(带 reviewResult 字段)
|
||||
if (out.review_result) {
|
||||
const reviewResult = normalizeReviewResult(out.review_result);
|
||||
if (reviewResult) {
|
||||
// 如果 progress_events 里已有 review_ready(节点 emit 的)但字段不全,补上 reviewResult
|
||||
const existing = events.find((e) => e.type === 'review_ready');
|
||||
if (existing) {
|
||||
existing.reviewResult = reviewResult;
|
||||
} else {
|
||||
events.push({ type: 'review_ready', sessionId, message: '', reviewResult });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ``current_draft`` 出现 → 补全 draft_ready 的 draftMarkdown / draftTitle / revisionCount
|
||||
if (out.current_draft && typeof out.current_draft === 'object') {
|
||||
const d = out.current_draft as Record<string, unknown>;
|
||||
const draftReady = events.find((e) => e.type === 'draft_ready');
|
||||
const ev: ProgressEvent = draftReady ?? { type: 'draft_ready', sessionId, message: '' };
|
||||
if (!ev.draftMarkdown) ev.draftMarkdown = (d.full_markdown ?? d.fullMarkdown ?? '') as string;
|
||||
if (!ev.draftTitle) ev.draftTitle = (d.title ?? '') as string;
|
||||
if (ev.revisionCount == null) ev.revisionCount = (d.revision_count ?? d.revisionCount ?? 0) as number;
|
||||
if (!draftReady) events.push(ev);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/** ``stream.interrupt.value`` → await_user ProgressEvent。 */
|
||||
function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const v = value as Record<string, unknown>;
|
||||
return {
|
||||
type: 'await_user',
|
||||
sessionId,
|
||||
message: String(v.message ?? ''),
|
||||
pausePoint: v.pause_point as ProgressEvent['pausePoint'],
|
||||
materials: Array.isArray(v.materials) ? (v.materials as Record<string, unknown>[]).map(normalizeMaterial) : undefined,
|
||||
keywords: v.keywords as string[] | undefined,
|
||||
materialSummary: v.summary as string | undefined,
|
||||
outline: v.outline ? (normalizeOutline(v.outline) as AIOutline) : undefined,
|
||||
reviewResult: v.review_result ? (normalizeReviewResult(v.review_result) as ReviewResult) : undefined,
|
||||
draftTitle: v.draft_title as string | undefined,
|
||||
revisionCount: v.revision_count as number | undefined,
|
||||
blockedSections: Array.isArray(v.blocked_sections)
|
||||
? (v.blocked_sections as Record<string, unknown>[]).map(normalizeBlockedSection)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 主 hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type AIWritingValues = {
|
||||
user_intent?: string;
|
||||
article_type?: string;
|
||||
status?: string;
|
||||
material_package?: unknown;
|
||||
current_outline?: unknown;
|
||||
current_draft?: unknown;
|
||||
review_result?: unknown;
|
||||
progress_events?: unknown[];
|
||||
};
|
||||
|
||||
export function useAIWriting() {
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
||||
|
||||
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
|
||||
// 真实 thread_id,我们存到 state.sessionId。之后 reducer 直接读它。
|
||||
const threadId = state.sessionId ?? undefined;
|
||||
|
||||
// 把已处理的 interrupt id 记下来,避免 useStream re-render 时重复 dispatch
|
||||
const seenInterruptRef = useRef<string | null>(null);
|
||||
|
||||
const stream = useStream<AIWritingValues>({
|
||||
client: getAPIClient(),
|
||||
assistantId: 'ai_writing',
|
||||
threadId,
|
||||
reconnectOnMount: true,
|
||||
onCreated(meta) {
|
||||
// 第一次提交时拿到的 thread_id == 业务表 ai_writing_sessions.id
|
||||
dispatch({ type: 'set_session', sessionId: meta.thread_id });
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
const pe = customToProgressEvent(event, state.sessionId ?? '');
|
||||
if (pe) dispatch({ type: 'handle', event: pe });
|
||||
},
|
||||
onUpdateEvent(data: Record<string, unknown> | undefined) {
|
||||
// data 形如 { node_name: { ...node_output } },可能多个节点同一 step
|
||||
if (!data) return;
|
||||
// 兜底:updates 通道可能直接带 ``__interrupt__`` 数组(节点进 interrupt 时
|
||||
// LangGraph 推 ``{__interrupt__: [...]}``),SDK 主路径只在 values 通道
|
||||
// 暴露 stream.interrupt —— 万一后端 serialize 又把 __interrupt__ 滤掉,
|
||||
// 这里直接合成 await_user 不至于卡死 UI。
|
||||
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
|
||||
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
|
||||
for (const intr of interrupts) {
|
||||
const value = (intr && typeof intr === 'object' && 'value' in intr)
|
||||
? (intr as { value: unknown }).value : intr;
|
||||
const ev = interruptToEvent(value, state.sessionId ?? '');
|
||||
if (ev) dispatch({ type: 'handle', event: ev });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const nodeOutput of Object.values(data)) {
|
||||
const events = nodeUpdatesToEvents(nodeOutput, state.sessionId ?? '');
|
||||
for (const ev of events) dispatch({ type: 'handle', event: ev });
|
||||
}
|
||||
},
|
||||
onError(err: unknown) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
},
|
||||
onFinish(state) {
|
||||
// graph 自然终止(END)—— 合成一条 done 事件,UI 切到 done 状态
|
||||
const finalStatus = (state.values as AIWritingValues | undefined)?.status;
|
||||
if (finalStatus === 'done' || finalStatus === 'error') {
|
||||
dispatch({
|
||||
type: 'handle',
|
||||
event: {
|
||||
type: finalStatus === 'error' ? 'error' : 'done',
|
||||
sessionId: state.values?.sessionId as string ?? '',
|
||||
message: finalStatus === 'error' ? '写作出错' : '写作完成',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// interrupt → await_user:useStream 每次 re-render 都会暴露当前 interrupt,
|
||||
// 我们用 id 去重;老 hook 的 reducer 本身对 pausePoint 也有去重,双保险。
|
||||
useEffect(() => {
|
||||
const intr = stream.interrupt;
|
||||
if (!intr) {
|
||||
seenInterruptRef.current = null;
|
||||
return;
|
||||
}
|
||||
const key = intr.id ?? JSON.stringify(intr.value);
|
||||
if (seenInterruptRef.current === key) return;
|
||||
seenInterruptRef.current = key;
|
||||
const ev = interruptToEvent(intr.value, state.sessionId ?? '');
|
||||
if (ev) dispatch({ type: 'handle', event: ev });
|
||||
}, [stream.interrupt, state.sessionId]);
|
||||
|
||||
// 草稿实时同步到右侧 Artifact 面板(与老 hook 行为一致)
|
||||
useEffect(() => {
|
||||
if (state.currentDraftMarkdown) {
|
||||
setArtifact({
|
||||
currentIndex: 0,
|
||||
contents: [{
|
||||
index: 0,
|
||||
type: 'text',
|
||||
title: state.currentDraftTitle || '草稿',
|
||||
fullMarkdown: state.currentDraftMarkdown,
|
||||
}],
|
||||
});
|
||||
}
|
||||
}, [state.currentDraftMarkdown, state.currentDraftTitle, setArtifact]);
|
||||
|
||||
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
|
||||
useEffect(() => {
|
||||
const s = state.status;
|
||||
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
||||
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
|
||||
saveAIWritingTranscript(state.sessionId, {
|
||||
progressEvents: state.progressEvents,
|
||||
completedInterventions: state.completedInterventions,
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status, state.sessionId]);
|
||||
|
||||
// ── 操作 API ────────────────────────────────────────────────────────────────
|
||||
|
||||
const startWriting = useCallback(async (request: AIWritingRequest) => {
|
||||
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
|
||||
dispatch({ type: 'start', request });
|
||||
try {
|
||||
await stream.submit(
|
||||
{
|
||||
user_intent: request.userIntent,
|
||||
article_type: request.articleType,
|
||||
target_audience: request.targetAudience,
|
||||
word_count_target: request.wordCountTarget,
|
||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
drafts: [],
|
||||
progress_events: [],
|
||||
material_package: null,
|
||||
current_outline: null,
|
||||
current_draft: null,
|
||||
review_result: null,
|
||||
last_user_intervention: null,
|
||||
} as Partial<AIWritingValues>,
|
||||
{
|
||||
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
|
||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [state.status, stream]);
|
||||
|
||||
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
|
||||
if (!state.sessionId || !state.currentPause) return;
|
||||
|
||||
const pausePoint = state.currentPause.pausePoint;
|
||||
const record: CompletedIntervention = {
|
||||
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
||||
action: payload.action,
|
||||
};
|
||||
if (pausePoint === 'material_confirm') record.materials = state.materials;
|
||||
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
|
||||
else if (pausePoint === 'draft_confirm') {
|
||||
record.draftTitle = state.currentDraftTitle;
|
||||
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
}
|
||||
|
||||
// pendingRevisionContext:编辑打回卡提交时记录本轮被选中 / 跳过的意见,draft_ready 后用于生成摘要
|
||||
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
|
||||
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
|
||||
if (isApplyingReview && state.reviewResult) {
|
||||
const allIssues = state.reviewResult.revisionNotes;
|
||||
const selectedSet = new Set(payload.selectedIssueIndices!);
|
||||
pendingRevisionContext = {
|
||||
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
|
||||
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
|
||||
oldWordCount: countWords(state.currentDraftMarkdown),
|
||||
};
|
||||
}
|
||||
|
||||
// 乐观更新到下一阶段,跟老 hook 行为对齐
|
||||
let nextStatus: WritingStatus = state.status;
|
||||
if (pausePoint === 'material_confirm') {
|
||||
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
|
||||
} else if (pausePoint === 'outline_confirm') {
|
||||
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
|
||||
} else if (pausePoint === 'draft_confirm') {
|
||||
if (payload.action === 'finalize') nextStatus = 'done';
|
||||
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
|
||||
else nextStatus = 'revising';
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
|
||||
} else if (pausePoint === 'section_help') {
|
||||
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
|
||||
? 'researching' : 'writing_draft';
|
||||
}
|
||||
|
||||
// 后端 pause 节点期望的 resume payload 字段名:保持 camelCase(节点里直接 user_input.get('action')
|
||||
// / approvedMaterialIds 等),所以这里**不要**转 snake_case。
|
||||
const resumePayload: Record<string, unknown> = {
|
||||
action: payload.action,
|
||||
};
|
||||
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
|
||||
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
|
||||
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
|
||||
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
|
||||
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
|
||||
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
|
||||
if (payload.sectionDecisions !== undefined) {
|
||||
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
|
||||
sectionTitle: d.sectionTitle, decision: d.decision,
|
||||
}));
|
||||
}
|
||||
|
||||
seenInterruptRef.current = null; // 允许下一个 interrupt 被消费
|
||||
|
||||
// ⚠️ 乐观更新必须放在 await 之前 ——
|
||||
// ``stream.submit(undefined, {command})`` 返回的 Promise **要等整条流跑完**
|
||||
// (resume → 跑 writer_outline → 推 outline_chunk → 到达 pause_outline_confirm
|
||||
// → 推新 interrupt → end) 才 resolve。这中间 useEffect(stream.interrupt) 已经
|
||||
// 把 currentPause 更新到了 outline_confirm;若我们在 await 后才 dispatch
|
||||
// ``intervention_submitted``(含 currentPause: null),就会把新弹的卡片擦掉,
|
||||
// UI 永远卡在 writing_outline skeleton。
|
||||
//
|
||||
// 老 hook 没这个问题:``resumeWritingApi`` 是 POST /resume,几十毫秒就返回。
|
||||
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
|
||||
|
||||
try {
|
||||
await stream.submit(undefined, {
|
||||
command: { resume: resumePayload },
|
||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||
state.status, stream]);
|
||||
|
||||
const pauseWriting = useCallback(() => {
|
||||
void stream.stop();
|
||||
dispatch({ type: 'pause' });
|
||||
}, [stream]);
|
||||
|
||||
const resumeWriting = useCallback(() => {
|
||||
// useStream 在挂载 / threadId 变化时会自动 reconnect,不需要显式 attach。
|
||||
dispatch({ type: 'resume_ui' });
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
void stream.stop();
|
||||
setArtifact(null);
|
||||
seenInterruptRef.current = null;
|
||||
dispatch({ type: 'reset' });
|
||||
}, [stream, setArtifact]);
|
||||
|
||||
// 与老 hook 同名导出对象
|
||||
return useMemo(
|
||||
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
|
||||
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,6 @@ import type { GroupImperativeHandle } from 'react-resizable-panels';
|
||||
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
|
||||
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
|
||||
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
||||
import { AIWritingPageHeader } from '../components/ai-writing/AIWritingPageHeader';
|
||||
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
||||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||||
import {
|
||||
@@ -113,8 +112,6 @@ export default function AIWritingPage() {
|
||||
<CanvasUIProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<AIWritingPageHeader />
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
|
||||
Reference in new issue
Block a user