This commit is contained in:
董宏凯 committed 2026-05-17 13:08:45 +08:00
1 parent 4533755704
commit cc8703577d
42 files changed
+5909 -5

No files matched your search

File diff suppressed because it is too large. Load diff
+107
View File
@@ -0,0 +1,107 @@
# AI 智能写作 — 手工测试清单
> 测试环境:后端 `http://localhost:8001`,前端 `http://localhost:5174`
> 每项测试前确认后端已启动(`make dev`)、前端已启动(`pnpm dev`)
---
## T1 — 启动写作任务
| # | 步骤 | 预期结果 |
|---|------|----------|
| 1.1 | 打开 Canvas 页面,点击右上角「AI 写作」按钮 | AIWritingPanel 在右侧展开,显示写作配置表单 |
| 1.2 | 填写:意图「新能源汽车市场趋势分析」,类型「分析报告」,目标读者「行业研究人员」,字数「1200」 | 表单验证通过,提交按钮可点击 |
| 1.3 | 点击「开始写作」 | 按钮变为 loading 状态;时间轴出现第一条「素材收集专家 · 开始检索素材」进度条目 |
| 1.4 | 意图字段留空后点击提交 | 表单校验阻止提交,提示「请填写写作意图」 |
---
## T2 — SSE 进度流(时间轴)
| # | 步骤 | 预期结果 |
|---|------|----------|
| 2.1 | 任务启动后观察时间轴 | 每隔几秒新增进度条目,agent_name 标签分别出现「素材收集专家」「作家」「编辑」 |
| 2.2 | 打开浏览器 DevTools → Network,过滤 `stream` | 看到一个 `text/event-stream` 请求长连接,持续收到 `data:` 帧 |
| 2.3 | 断网 5 秒后恢复 | 面板显示「连接中断」提示(或 error 事件),不崩溃 |
---
## T3 — PAUSE-1 素材确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 3.1 | 等待素材收集专家完成检索,出现 PAUSE-1 干预卡 | 卡片标题「素材确认」,列出 ≥1 条素材,每条有复选框、标题、来源、相关度分数色条 |
| 3.2 | 全选所有素材,点击「确认素材,继续写作」 | 干预卡禁用,时间轴新增「作家 · 开始规划大纲」 |
| 3.3 | 重新发起任务,PAUSE-1 出现后取消勾选全部素材,点击「重新检索」 | 向后端发送 `action: re_search`;时间轴回到素材收集专家节点重新检索 |
| 3.4 | PAUSE-1 出现后填写「追加关键词」输入框(如「锂电池」)并按 Enter,再点「追加搜索」 | 标签出现「锂电池」;后端收到 `action: append_search, extraKeywords: ["锂电池"]` |
| 3.5 | 验证相关度色条颜色:分数 ≥0.8 绿色 / 0.5–0.8 橙色 / <0.5 红色 | 三种颜色均可在不同素材中观察到 |
---
## T4 — PAUSE-2 大纲确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 4.1 | PAUSE-2 出现后检查大纲卡 | 显示文章标题 + ≥2 个章节,每节有节标题与要点列表 |
| 4.2 | 点击文章标题,内联编辑改为「2025 新能源趋势报告」,按 Enter | 标题实时变更,不刷新页面 |
| 4.3 | 展开某章节,点击一条要点,修改文字,按 Blur 失焦 | 要点文字更新 |
| 4.4 | 点击「+ 新增章节」,填写章节标题 | 大纲末尾追加新章节 |
| 4.5 | 点击「确认大纲,开始写作」 | 编辑后的大纲随 `action: confirm_outline` 提交;时间轴出现「作家 · 开始撰写草稿」 |
| 4.6 | PAUSE-2 出现后点击「重新规划大纲」 | 显示意见输入框;填写「请增加政策分析章节」后提交;后端收到 `action: re_outline, outlineFeedback: ...` |
---
## T5 — PAUSE-3 草稿确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 5.1 | 作家完成草稿,PAUSE-3 出现 | 干预卡显示修改次数(如「第 1 稿」);右侧 Artifact 面板同步显示 Markdown 草稿内容 |
| 5.2 | 点击「定稿,提交编辑审核」 | 进入编辑节点;时间轴出现「编辑 · 开始审核」 |
| 5.3 | 点击「提出修改意见」,填写意见后提交 | 作家节点重新生成草稿;修改次数 +1 |
| 5.4 | 连续打回 3 次,观察第 4 次 PAUSE-3 | revisionCount 正确递增显示 |
---
## T6 — PAUSE-4 审核结果确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 6.1 | 编辑审核完成,PAUSE-4 出现 | ReviewCard 显示:综合得分、事实/逻辑/语言三项分数、及格线(默认 75)、verdict 徽章(通过=绿 / 打回=红)|
| 6.2 | verdict=pass 时,点击「接受审核,定稿」 | 流程结束;时间轴出现「完成」;右侧面板保留最终草稿 |
| 6.3 | verdict=reject 时,查看问题列表 | major 问题排在 minor 前;每条显示类别徽章、位置、问题描述、建议 |
| 6.4 | verdict=reject 时,点击「强制定稿」 | 向后端发送 `action: force_finalize`;流程结束 |
| 6.5 | 添加「额外说明」文字后点击「接受审核,定稿」 | extra_notes 包含在提交的 intervention payload 中(DevTools → Network 验证) |
---
## T7 — 错误与边界场景
| # | 步骤 | 预期结果 |
|---|------|----------|
| 7.1 | 后端未启动时点击「开始写作」 | 面板显示错误提示「无法连接到服务器」,不白屏 |
| 7.2 | 写作进行中点击「停止/重置」 | 时间轴清空,表单恢复可编辑,后端流被中断(Network 连接关闭)|
| 7.3 | 素材列表为空(后端返回 0 条)时 PAUSE-1 | 干预卡显示「暂无素材」提示,「确认」按钮仍可点击 |
| 7.4 | 大纲只有 1 个章节时,点击删除该章节 | 删除按钮禁用或弹出「至少保留一个章节」提示 |
| 7.5 | 弱模型返回格式错误的 JSON(手动 mock 后端) | 前端收到 fallback 结构,不崩溃;时间轴正常继续 |
---
## T8 — 跨阶段状态一致性
| # | 步骤 | 预期结果 |
|---|------|----------|
| 8.1 | PAUSE-2 时展开大纲卡,刷新页面(F5) | 页面恢复;如写作任务 ID 持久化,状态可恢复(否则回到初始状态,均可接受)|
| 8.2 | 同时打开两个浏览器标签,各自启动独立写作任务 | 两个任务互不干扰,各自有独立 task_id |
| 8.3 | 完整跑通一次全流程(T1→T2→T3→T4→T5→T6 pass) | 右侧 Artifact 面板显示完整定稿文章,时间轴所有节点状态为「完成」 |
---
## 后端单元测试(补充确认)
```bash
# 在 offline-backend-20260512/backend/ 下运行
PYTHONPATH=. uv run pytest tests/test_ai_writing_json_parse.py -v # 16 tests
PYTHONPATH=. uv run pytest tests/test_ai_writing_graph.py -v # 19 tests
```
两个测试文件均应全部通过(0 failed)。
@@ -0,0 +1,195 @@
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 ?? '',
}),
});
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', 'materials_ready', 'outline_ready',
'draft_chunk', 'draft_ready', 'review_ready', 'await_user', 'resumed', '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);
// 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);
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,
}),
});
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
}
@@ -0,0 +1,171 @@
import { useEffect, useState } from 'react';
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { AIWritingRequest } from '../../types/ai-writing';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
}
interface ModelOption {
name: string;
displayName: string;
}
const ARTICLE_TYPES: { value: AIWritingRequest['articleType']; label: string }[] = [
{ value: 'analysis', label: '分析报道' },
{ value: 'news', label: '新闻资讯' },
{ value: 'feature', label: '深度特稿' },
{ value: 'report', label: '调研报告' },
];
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] =
useState<AIWritingRequest['articleType']>('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCount, setWordCount] = useState(800);
const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]);
useEffect(() => {
apiFetch(`${getBackendBaseURL()}/api/models`)
.then((r) => r.json())
.then((data) => {
const list: ModelOption[] = (data.models ?? []).map(
(m: { name: string; display_name?: string }) => ({
name: m.name,
displayName: m.display_name || m.name,
}),
);
setModels(list);
if (list.length > 0 && !modelName) {
setModelName(list[0].name);
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
onSubmit({
userIntent: intent.trim(),
articleType,
targetAudience: audience.trim() || '普通读者',
wordCountTarget: wordCount,
modelName: modelName || undefined,
});
}
return (
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
<Textarea
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={4}
className="resize-none text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
<Select
value={articleType}
onValueChange={(v) => setArticleType(v as AIWritingRequest['articleType'])}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ARTICLE_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标字数</label>
<Select
value={String(wordCount)}
onValueChange={(v) => setWordCount(Number(v))}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[500, 800, 1200, 2000, 3000].map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{n} 字
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
{models.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
<Select
value={modelName}
onValueChange={setModelName}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="选择模型…" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
<PenLine className="size-3.5" />
开始 AI 写作
</Button>
</form>
);
}
@@ -0,0 +1,107 @@
import { RotateCcw, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useAIWriting } from '../../hooks/useAIWriting';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
const STATUS_LABEL: Record<string, string> = {
idle: '就绪',
researching: '素材收集专家搜集素材中…',
awaiting_material_confirm: '等待确认素材',
writing_outline: '作家规划大纲中…',
awaiting_outline_confirm: '等待确认大纲',
writing_draft: '作家写作中…',
awaiting_draft_confirm: '等待确认草稿',
reviewing: '编辑审核中…',
awaiting_review_confirm: '等待处理编辑意见',
revising: '按意见修改中…',
done: '写作完成 ✓',
error: '出错了',
};
export function AIWritingPanel() {
const { state, startWriting, submitIntervention, stopWriting, reset } = useAIWriting();
const isIdle = state.status === 'idle';
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold">AI 智能写作</span>
<div className="flex items-center gap-1.5">
{isRunning && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={stopWriting}>
<Square className="size-3" />
停止
</Button>
)}
{(isDone || isError || isRunning) && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
)}
</div>
</div>
{/* 主体:可滚动 */}
<div className="flex-1 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm onSubmit={startWriting} />
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
<AIWritingTimeline
events={state.progressEvents}
interventionSlot={
state.currentPause ? (
<InterventionCard
pauseEvent={state.currentPause}
materials={state.materials}
outline={state.outline}
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
revisionCount={state.revisionCount}
onSubmit={submitIntervention}
disabled={false}
/>
) : undefined
}
/>
)}
{/* 出错提示 */}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
</div>
)}
</div>
{/* 状态栏 */}
{!isIdle && (
<div
className={cn(
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{STATUS_LABEL[state.status] ?? state.status}
</div>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import type { ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
interface AIWritingTimelineProps {
events: ProgressEvent[];
/**
* 各干预点的专属内容插槽。
* 在阶段四/五/六填充素材卡、大纲卡、审核卡等组件。
*/
interventionSlot?: React.ReactNode;
}
const DISPLAYED_TYPES = new Set([
'step_start',
'step_done',
'materials_ready',
'outline_ready',
'draft_ready',
'review_ready',
'await_user',
'resumed',
'done',
'error',
]);
export function AIWritingTimeline({ events, interventionSlot }: AIWritingTimelineProps) {
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
if (visible.length === 0) return null;
return (
<div className="px-4 py-3 space-y-0">
{visible.map((event, idx) => {
const isLast = idx === visible.length - 1;
// 在最后一个 await_user 事件后插入干预插槽
const showSlot = isLast && event.type === 'await_user' && interventionSlot;
return (
<TimelineStep key={idx} event={event} isLast={isLast && !showSlot}>
{showSlot ? interventionSlot : null}
</TimelineStep>
);
})}
</div>
);
}
@@ -0,0 +1,398 @@
import { useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
AIMaterial, AIOutline, ReviewResult,
ProgressEvent, UserInterventionPayload,
} from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
interface InterventionCardProps {
pauseEvent: ProgressEvent;
materials: AIMaterial[];
outline: AIOutline | null;
reviewResult: ReviewResult | null;
draftTitle: string;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
}
if (pausePoint === 'outline_confirm') {
return (
<OutlineConfirmCard
outline={outline ?? pauseEvent.outline ?? null}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'draft_confirm') {
return (
<DraftConfirmCard
draftTitle={draftTitle}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmCard
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
revisionCount={revisionCount}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
return (
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
等待用户确认({pausePoint})…
</div>
);
}
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
interface MaterialConfirmCardProps {
materials: AIMaterial[];
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
setSubmitting(false);
}
const hasExtra = extraKeywords.length > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
</p>
</div>
</div>
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}} />
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
/>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
</div>
);
}
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
interface OutlineConfirmCardProps {
outline: AIOutline | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [showReplan, setShowReplan] = useState(false);
const [feedback, setFeedback] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({
action: 'confirm',
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
});
setSubmitting(false);
}
async function handleReplan() {
setSubmitting(true);
await onSubmit({ action: 're_outline', outlineFeedback: feedback.trim() });
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">请确认大纲后继续写作</p>
<p className="text-xs text-muted-foreground mt-0.5">
可直接点击章节标题或要点进行编辑,或要求重新规划大纲。
</p>
</div>
</div>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
{showReplan && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">重新规划意见(可选)</label>
<textarea
rows={2} value={feedback} onChange={(e) => setFeedback(e.target.value)}
placeholder="描述大纲调整方向,例如:增加案例分析章节…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
{showReplan ? (
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReplan} disabled={disabled || submitting}
>
<RefreshCw className="size-3.5" />提交重规划
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => setShowReplan(true)} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新规划…
</Button>
)}
</div>
</div>
);
}
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
interface DraftConfirmCardProps {
draftTitle: string;
revisionCount: number;
reviewResult: ReviewResult | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
const [notes, setNotes] = useState('');
const [showNotes, setShowNotes] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
setSubmitting(true);
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容
</p>
</div>
</div>
{/* 编辑审核分数(若已经过编辑)*/}
{reviewResult && (
<ReviewCard review={reviewResult} revisionCount={revisionCount} />
)}
{/* 修改意见输入 */}
{showNotes && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">您的修改意见</label>
<textarea
rows={2} value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="描述希望修改的内容,例如:第二段数据来源不清晰,结尾过于仓促…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex flex-col gap-1.5 pt-1">
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
<div className="flex gap-1.5">
{showNotes ? (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('user_revise')} disabled={disabled || submitting || !notes.trim()}
>
<PenLine className="size-3.5" />按我的意见修改
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => setShowNotes(true)} disabled={disabled || submitting}
>
<PenLine className="size-3.5" />给出修改意见…
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
</div>
</div>
</div>
);
}
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
interface ReviewConfirmCardProps {
reviewResult: ReviewResult | null;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
const [extraNotes, setExtraNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleAccept() {
setSubmitting(true);
await onSubmit({
action: 'accept_review',
userRevisionNotes: extraNotes.trim() || undefined,
});
setSubmitting(false);
}
async function handleForce() {
setSubmitting(true);
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">编辑认为文章需要修改</p>
<p className="text-xs text-muted-foreground mt-0.5">
请决定下一步操作:采纳编辑意见重写,或忽略编辑意见强制定稿。
</p>
</div>
</div>
{reviewResult
? <ReviewCard review={reviewResult} revisionCount={revisionCount} />
: <p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充修改指令(可选,将覆盖编辑意见)</label>
<textarea
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
placeholder="留空则直接采纳编辑意见;或在此补充您自己的修改方向…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleAccept} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />采纳编辑意见,让作家重写
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { cn } from '@/lib/utils';
import type { ReviewIssue } from '../../types/ai-writing';
interface IssueListProps {
issues: ReviewIssue[];
}
const CATEGORY_LABEL: Record<string, string> = {
fact: '事实',
logic: '逻辑',
language: '语言',
};
const CATEGORY_COLOR: Record<string, string> = {
fact: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
logic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400',
language: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
};
export function IssueList({ issues }: IssueListProps) {
if (issues.length === 0) {
return <p className="text-xs text-muted-foreground text-center py-1">无需修改意见</p>;
}
const major = issues.filter((i) => i.severity === 'major');
const minor = issues.filter((i) => i.severity === 'minor');
return (
<div className="space-y-3">
{major.length > 0 && (
<IssueGroup label="必须修改" labelClass="text-red-500" issues={major} />
)}
{minor.length > 0 && (
<IssueGroup label="建议修改" labelClass="text-yellow-500" issues={minor} />
)}
</div>
);
}
interface IssueGroupProps {
label: string;
labelClass: string;
issues: ReviewIssue[];
}
function IssueGroup({ label, labelClass, issues }: IssueGroupProps) {
return (
<div className="space-y-1.5">
<p className={cn('text-xs font-semibold', labelClass)}>{label}</p>
{issues.map((issue, i) => (
<div key={i} className="rounded border border-border/60 bg-background px-2.5 py-2 space-y-1 text-xs">
<div className="flex items-center gap-1.5">
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', CATEGORY_COLOR[issue.category] ?? 'bg-muted text-muted-foreground')}>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<span className="text-muted-foreground truncate">{issue.location}</span>
</div>
<p className="text-foreground leading-snug">{issue.issue}</p>
<p className="text-muted-foreground leading-snug">
<span className="text-primary mr-1">→</span>
{issue.suggestion}
</p>
</div>
))}
</div>
);
}
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialItemProps {
material: AIMaterial;
checked: boolean;
onToggle: (id: string) => void;
}
function relevanceColor(score: number): string {
if (score >= 0.8) return 'bg-green-500';
if (score >= 0.5) return 'bg-orange-400';
return 'bg-red-400';
}
function relevanceLabel(score: number): string {
if (score >= 0.8) return '高相关';
if (score >= 0.5) return '中相关';
return '低相关';
}
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
const [expanded, setExpanded] = useState(false);
const pct = Math.round(material.relevanceScore * 100);
return (
<div
className={cn(
'rounded-md border text-xs transition-colors',
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
)}
>
{/* 头部行 */}
<div className="flex items-start gap-2 px-2.5 py-2">
{/* Checkbox */}
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(material.id)}
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-input accent-primary cursor-pointer"
/>
{/* 标题 + 展开按钮 */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex flex-1 items-start gap-1.5 text-left"
>
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
{material.title}
</span>
{expanded
? <ChevronDown className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />}
</button>
</div>
{/* 元信息行 */}
<div className="flex items-center gap-2 px-2.5 pb-2 pl-8">
<span className="text-muted-foreground truncate max-w-[120px]">{material.source}</span>
{material.publishedAt && (
<>
<span className="text-muted-foreground/40">·</span>
<span className="text-muted-foreground shrink-0">{material.publishedAt}</span>
</>
)}
{/* 相关度色条 */}
<div className="flex flex-1 items-center gap-1 min-w-0">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
<div
className={cn('h-full rounded-full transition-all', relevanceColor(material.relevanceScore))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn('shrink-0', relevanceColor(material.relevanceScore).replace('bg-', 'text-'))}>
{pct}%
</span>
<span className="text-muted-foreground/60 shrink-0">({relevanceLabel(material.relevanceScore)})</span>
</div>
</div>
{/* 展开内容 */}
{expanded && (
<div className="px-2.5 pb-2.5 pl-8 space-y-1.5 border-t">
<p className="text-muted-foreground leading-relaxed pt-2 line-clamp-6">
{material.content.slice(0, 300)}
{material.content.length > 300 && '…'}
</p>
{material.url && (
<a
href={material.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
查看原文
<ExternalLink className="size-2.5" />
</a>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, FileText } from 'lucide-react';
import type { AIMaterial } from '../../types/ai-writing';
import { MaterialItem } from './MaterialItem';
interface MaterialsCardProps {
materials: AIMaterial[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
}
export function MaterialsCard({ materials, selectedIds, onToggle }: MaterialsCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 折叠头 */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-muted/40 transition-colors text-left"
>
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1">
素材列表
<span className="ml-1 text-muted-foreground font-normal">
(共 {materials.length} 条,已选 {selectedIds.size} 条)
</span>
</span>
{expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />}
</button>
{/* 列表 */}
{expanded && (
<div className="px-2.5 pb-2.5 space-y-1.5 border-t">
{materials.length === 0 ? (
<p className="text-xs text-muted-foreground py-2 text-center">暂无素材</p>
) : (
materials.map((m) => (
<MaterialItem
key={m.id}
material={m}
checked={selectedIds.has(m.id)}
onToggle={onToggle}
/>
))
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialsToolbarProps {
materials: AIMaterial[];
selectedIds: Set<string>;
onSelectAll: () => void;
onDeselectAll: () => void;
extraKeywords: string[];
onAddKeyword: (kw: string) => void;
onRemoveKeyword: (kw: string) => void;
}
export function MaterialsToolbar({
materials,
selectedIds,
onSelectAll,
onDeselectAll,
extraKeywords,
onAddKeyword,
onRemoveKeyword,
}: MaterialsToolbarProps) {
const [kwInput, setKwInput] = useState('');
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
function handleAddKeyword() {
const kw = kwInput.trim();
if (!kw || extraKeywords.includes(kw)) return;
onAddKeyword(kw);
setKwInput('');
}
return (
<div className="space-y-2">
{/* 全选/取消 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={allSelected ? onDeselectAll : onSelectAll}
className="text-xs text-primary hover:underline"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
{/* 补充关键词 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
<div className="flex gap-1.5">
<input
type="text"
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
placeholder="输入关键词后按 Enter 或点击 +"
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handleAddKeyword}
disabled={!kwInput.trim()}
>
<Plus className="size-3" />
</Button>
</div>
{/* 已添加关键词 Tag 列表 */}
{extraKeywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{extraKeywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
onClick={() => onRemoveKeyword(kw)}
className="hover:text-destructive leading-none"
>
×
</button>
</span>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import { useState } from 'react';
import { ListTree, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIOutline, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { OutlineSection } from './OutlineSection';
interface OutlineCardProps {
outline: AIOutline;
onChange?: (updated: AIOutline) => void;
readOnly?: boolean;
}
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
const [collapsed, setCollapsed] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(outline.title);
const editable = !readOnly && !!onChange;
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== outline.title) {
onChange?.({ ...outline, title: trimmed });
} else {
setTitleDraft(outline.title);
}
}
function updateSection(i: number, updated: OutlineSectionType) {
const sections = [...outline.sections];
sections[i] = updated;
onChange?.({ ...outline, sections });
}
function removeSection(i: number) {
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
}
function addSection() {
onChange?.({
...outline,
sections: [...outline.sections, { sectionTitle: '', keyPoints: [], materialIds: [] }],
});
}
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 头部 */}
<div className="flex items-center gap-2 px-3 py-2 border-b">
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium flex-1">文章大纲</span>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground"
>
{collapsed ? '展开' : '收起'}
</button>
</div>
{!collapsed && (
<div className="px-3 py-2.5 space-y-2">
{/* 文章标题 */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground shrink-0">标题:</span>
{editingTitle && editable ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(outline.title); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-semibold"
/>
) : (
<span
className={editable ? 'flex-1 font-semibold cursor-text hover:text-primary' : 'flex-1 font-semibold'}
onClick={() => editable && setEditingTitle(true)}
>
{outline.title || <span className="text-muted-foreground italic font-normal">(未设置标题)</span>}
</span>
)}
</div>
{/* 章节列表 */}
<div className="space-y-1.5">
{outline.sections.map((section, i) => (
<OutlineSection
key={i}
section={section}
index={i}
onChange={(updated) => updateSection(i, updated)}
onRemove={editable ? () => removeSection(i) : undefined}
readOnly={!editable}
/>
))}
</div>
{/* 添加章节 */}
{editable && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full h-7 text-xs gap-1.5 text-muted-foreground"
onClick={addSection}
>
<Plus className="size-3" />
添加章节
</Button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,126 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, Plus, X, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
interface OutlineSectionProps {
section: OutlineSectionType;
index: number;
onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void;
readOnly?: boolean;
}
export function OutlineSection({ section, index, onChange, onRemove, readOnly }: OutlineSectionProps) {
const [expanded, setExpanded] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== section.sectionTitle) {
onChange({ ...section, sectionTitle: trimmed });
} else {
setTitleDraft(section.sectionTitle);
}
}
function updateKeyPoint(i: number, value: string) {
const next = [...section.keyPoints];
next[i] = value;
onChange({ ...section, keyPoints: next });
}
function removeKeyPoint(i: number) {
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
}
function addKeyPoint() {
onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
}
return (
<div className="rounded border border-border/60 bg-background text-xs">
{/* 章节头 */}
<div className="flex items-center gap-1.5 px-2.5 py-2">
{!readOnly && <GripVertical className="size-3 text-muted-foreground/40 shrink-0 cursor-grab" />}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
>
{expanded
? <ChevronDown className="size-3.5 text-muted-foreground" />
: <ChevronRight className="size-3.5 text-muted-foreground" />}
</button>
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
/>
) : (
<span
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
</span>
)}
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
{!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
{/* 要点列表 */}
{expanded && (
<div className="px-8 pb-2.5 space-y-1 border-t">
{section.keyPoints.map((kp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1.5">
<span className="text-muted-foreground/40 shrink-0">•</span>
{readOnly ? (
<span className="flex-1 text-muted-foreground">{kp}</span>
) : (
<input
value={kp}
onChange={(e) => updateKeyPoint(i, e.target.value)}
placeholder="输入要点…"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加要点
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import { cn } from '@/lib/utils';
import type { ReviewResult } from '../../types/ai-writing';
import { ScoreBar } from './ScoreBar';
import { IssueList } from './IssueList';
interface ReviewCardProps {
review: ReviewResult;
revisionCount?: number;
}
export function ReviewCard({ review, revisionCount }: ReviewCardProps) {
const isPassed = review.verdict === 'pass';
return (
<div className="rounded-md border bg-muted/20 overflow-hidden text-xs">
{/* 头部:综合评分 + 轮次 */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<div className="flex items-center gap-2">
<span className="font-medium">编辑审核结果</span>
{revisionCount !== undefined && revisionCount > 0 && (
<span className="text-muted-foreground">(第 {revisionCount} 次修改)</span>
)}
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-[10px] font-semibold',
isPassed
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
)}
>
{isPassed ? '通过' : '打回'}
</span>
<span className="font-semibold tabular-nums">
{review.overallScore}
<span className="text-muted-foreground font-normal"> / 100</span>
</span>
</div>
</div>
{/* 分项评分 */}
<div className="px-3 py-2.5 space-y-2 border-b">
<ScoreBar label="事实准确性" score={review.factScore} />
<ScoreBar label="逻辑结构" score={review.logicScore} />
<ScoreBar label="语言文字" score={review.languageScore} />
<p className="text-muted-foreground/60 text-[10px]">
通过线:{review.passThreshold} 分
</p>
</div>
{/* 修改意见 */}
{review.revisionNotes.length > 0 && (
<div className="px-3 py-2.5">
<p className="text-muted-foreground font-medium mb-2">修改意见</p>
<IssueList issues={review.revisionNotes} />
</div>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { cn } from '@/lib/utils';
interface ScoreBarProps {
label: string;
score: number;
className?: string;
}
export function ScoreBar({ label, score, className }: ScoreBarProps) {
const clipped = Math.min(100, Math.max(0, score));
const color =
clipped >= 80
? 'bg-green-500'
: clipped >= 60
? 'bg-yellow-500'
: 'bg-red-500';
const verdict =
clipped >= 80 ? '优秀' : clipped >= 60 ? '需改进' : '不合格';
return (
<div className={cn('space-y-1', className)}>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{label}</span>
<span className="font-medium tabular-nums">
{clipped}
<span className="text-muted-foreground/60 ml-1">({verdict})</span>
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${clipped}%` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import { CheckCircle2, Circle, Loader2, PauseCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ProgressEvent } from '../../types/ai-writing';
type StepStatus = 'done' | 'running' | 'paused' | 'waiting';
interface TimelineStepProps {
event: ProgressEvent;
isLast?: boolean;
children?: React.ReactNode;
}
function statusOf(event: ProgressEvent): StepStatus {
if (event.type === 'await_user') return 'paused';
if (event.type === 'step_start') return 'running';
return 'done';
}
const AGENT_COLOR: Record<string, string> = {
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
编辑: 'text-orange-500',
系统: 'text-muted-foreground',
};
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
const status = statusOf(event);
return (
<div className="flex gap-3">
{/* 竖线 + 图标 */}
<div className="flex flex-col items-center">
<StatusIcon status={status} />
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* 内容 */}
<div className={cn('pb-4 flex-1 min-w-0', isLast && 'pb-0')}>
<div className="flex items-center gap-1.5 mb-0.5">
{event.agentName && (
<span
className={cn(
'text-xs font-semibold',
AGENT_COLOR[event.agentName] ?? 'text-muted-foreground',
)}
>
{event.agentName}
</span>
)}
<span className="text-xs text-muted-foreground">{event.message}</span>
</div>
{children}
</div>
</div>
);
}
function StatusIcon({ status }: { status: StepStatus }) {
switch (status) {
case 'done':
return <CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />;
case 'running':
return <Loader2 className="size-4 text-blue-500 shrink-0 mt-0.5 animate-spin" />;
case 'paused':
return <PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />;
default:
return <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />;
}
}
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import {
@@ -6,18 +6,23 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
type LeftMode = "chat" | "writing";
export function CanvasResizableLayout() {
const { isChatCollapsed } = useCanvasUIContext();
const layoutRef = useRef<GroupImperativeHandle>(null);
const [leftMode, setLeftMode] = useState<LeftMode>("chat");
useEffect(() => {
layoutRef.current?.setLayout(
@@ -39,9 +44,29 @@ export function CanvasResizableLayout() {
minSize={0}
collapsible
collapsedSize={0}
className="overflow-hidden"
className="overflow-hidden flex flex-col"
>
<CanvasChatPanel />
{/* 模式切换标签栏 */}
<div className="flex shrink-0 border-b bg-muted/30">
{(["chat", "writing"] as LeftMode[]).map((mode) => (
<button
key={mode}
onClick={() => setLeftMode(mode)}
className={cn(
"flex-1 py-1.5 text-xs font-medium transition-colors",
leftMode === mode
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{mode === "chat" ? "对话" : "AI 写作"}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { startWritingSession, subscribeToProgress, resumeWriting } from '../api/ai-writing';
import type {
AIWritingRequest,
AIWritingState,
AIMaterial,
AIOutline,
ProgressEvent,
ReviewResult,
UserInterventionPayload,
} from '../types/ai-writing';
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
progressEvents: [],
materials: [],
outline: null,
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
revisionCount: 0,
currentPause: null,
error: 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':
case 'step_done':
case 'resumed':
return event.type === 'resumed' ? { ...base, currentPause: null } : base;
case 'materials_ready':
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹)
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
};
case 'outline_ready':
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹)
return {
...base,
outline: (event.outline ?? null) as AIOutline | null,
};
case 'draft_ready':
return {
...base,
currentDraftMarkdown: event.draftMarkdown ?? '',
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
status: 'awaiting_draft_confirm',
currentPause: event,
};
case 'review_ready':
return {
...base,
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
};
case 'await_user': {
const pp = event.pausePoint;
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':
return { ...base, status: 'error', error: event.message ?? '未知错误' };
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' });
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) return;
try {
await resumeWriting(state.sessionId, payload);
setState((prev) => ({ ...prev, currentPause: null }));
} catch (err) {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId],
);
const stopWriting = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setState((prev) => ({ ...prev, status: 'idle' }));
}, []);
const reset = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setArtifact(null);
setState(INITIAL_STATE);
}, [setArtifact]);
useEffect(() => () => { abortRef.current?.abort(); }, []);
return { state, startWriting, submitIntervention, stopWriting, reset };
}
@@ -0,0 +1,138 @@
// ── 素材 ─────────────────────────────────────────────────────────────────────
export interface AIMaterial {
id: string;
title: string;
content: string;
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet';
url?: string;
recUuid?: string;
}
// ── 大纲 ─────────────────────────────────────────────────────────────────────
export interface OutlineSection {
sectionTitle: string;
keyPoints: string[];
materialIds: string[];
}
export interface AIOutline {
title: string;
sections: OutlineSection[];
}
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
export interface ReviewIssue {
category: 'fact' | 'logic' | 'language';
severity: 'major' | 'minor';
location: string;
issue: string;
suggestion: string;
}
export interface ReviewResult {
verdict: 'pass' | 'reject';
factScore: number;
logicScore: number;
languageScore: number;
overallScore: number;
passThreshold: number;
revisionNotes: ReviewIssue[];
}
// ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus =
| 'idle'
| 'researching'
| 'awaiting_material_confirm'
| 'writing_outline'
| 'awaiting_outline_confirm'
| 'writing_draft'
| 'awaiting_draft_confirm'
| 'reviewing'
| 'awaiting_review_confirm'
| 'revising'
| 'done'
| 'error';
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
export type ProgressEventType =
| 'connected'
| 'step_start'
| 'step_done'
| 'materials_ready'
| 'outline_ready'
| 'draft_chunk'
| 'draft_ready'
| 'review_ready'
| 'await_user'
| 'resumed'
| 'done'
| 'error';
export interface ProgressEvent {
type: ProgressEventType;
sessionId: string;
agentName?: string;
message: string;
// materials_ready
materials?: AIMaterial[];
keywords?: string[];
materialSummary?: string;
// outline_ready
outline?: AIOutline;
// draft_ready / draft_chunk
draftMarkdown?: string;
draftTitle?: string;
revisionCount?: number;
// review_ready
reviewResult?: ReviewResult;
// await_user
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
pauseContext?: Record<string, unknown>;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
export interface UserInterventionPayload {
action: string;
approvedMaterialIds?: string[];
extraKeywords?: string[];
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
overrideVerdict?: boolean;
}
// ── 写作请求 ──────────────────────────────────────────────────────────────────
export interface AIWritingRequest {
userIntent: string;
articleType: 'news' | 'analysis' | 'feature' | 'report';
targetAudience: string;
wordCountTarget: number;
modelName?: string;
}
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
progressEvents: ProgressEvent[];
materials: AIMaterial[];
outline: AIOutline | null;
currentDraftMarkdown: string;
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
currentPause: ProgressEvent | null;
error: string | null;
}
@@ -22,6 +22,7 @@ from app.gateway.config import get_gateway_config
from app.gateway.csrf_middleware import CSRFMiddleware
from app.gateway.deps import langgraph_runtime
from app.gateway.routers import (
ai_writing,
agents,
artifacts,
assistants_compat,
@@ -675,6 +676,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Writing rewrite API is mounted at /api/writing
app.include_router(writing.router)
# AI Writing multi-agent API is mounted at /api/ai-writing
app.include_router(ai_writing.router)
# Scheduled task API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
@@ -1,3 +1,3 @@
from . import artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads, writing
from . import ai_writing, artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads, writing
__all__ = ["artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads", "writing"]
__all__ = ["ai_writing", "artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads", "writing"]
@@ -0,0 +1,268 @@
"""AI 写作路由 —— 独立于原有路由,前缀 /api/ai-writing/
POST /api/ai-writing/start 启动写作流程,返回 session_id
GET /api/ai-writing/{id}/stream SSE 流(进度/干预/草稿事件)
POST /api/ai-writing/{id}/resume 用户干预响应,恢复流程
GET /api/ai-writing/{id}/state 查询当前流程状态
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import AsyncGenerator
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from langgraph.types import Command
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ai-writing", tags=["ai-writing"])
# ── 请求/响应模型 ─────────────────────────────────────────────────────────────
class StartWritingRequest(BaseModel):
user_intent: str
article_type: str = "analysis"
target_audience: str = "普通读者"
word_count_target: int = 800
model_name: str = ""
class StartWritingResponse(BaseModel):
session_id: str
message: str = "写作流程已启动"
class ResumeRequest(BaseModel):
action: str
approved_material_ids: list[str] = Field(default_factory=list)
extra_keywords: list[str] = Field(default_factory=list)
edited_outline: Optional[dict] = None
outline_feedback: str = ""
user_revision_notes: str = ""
override_verdict: bool = False
# ── 内部状态 ──────────────────────────────────────────────────────────────────
# session_id → asyncio.Queue
_event_queues: dict[str, asyncio.Queue] = {}
# session_id → model_name(resume 时复用)
_session_model_names: dict[str, str] = {}
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _make_config(session_id: str, model_name: str = "") -> dict:
return {
"configurable": {
"thread_id": session_id,
"model_name": model_name or _session_model_names.get(session_id, ""),
"ai_writing_config": {},
}
}
# ── 路由 ──────────────────────────────────────────────────────────────────────
@router.post("/start", response_model=StartWritingResponse)
async def start_writing(body: StartWritingRequest):
"""启动写作流程,返回 session_id。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
session_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue()
_event_queues[session_id] = queue
_session_model_names[session_id] = body.model_name
initial_state = {
"user_intent": body.user_intent,
"article_type": body.article_type,
"target_audience": body.target_audience,
"word_count_target": body.word_count_target,
"revision_count": 0,
"max_revisions": 3,
"status": "researching",
"drafts": [],
"progress_events": [],
"material_package": None,
"current_outline": None,
"current_draft": None,
"review_result": None,
"last_user_intervention": None,
}
config = _make_config(session_id, body.model_name)
asyncio.create_task(_stream_graph_segment(session_id, initial_state, config, AI_WRITING_GRAPH))
return StartWritingResponse(session_id=session_id)
@router.get("/{session_id}/stream")
async def stream_progress(session_id: str):
"""SSE 流:推送进度事件、干预请求、草稿内容。"""
return StreamingResponse(
_sse_generator(session_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/{session_id}/resume")
async def resume_writing(session_id: str, body: ResumeRequest):
"""用户提交干预决策,恢复暂停的流程。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
if session_id not in _event_queues:
raise HTTPException(status_code=404, detail="Session not found")
config = _make_config(session_id)
intervention = {
"action": body.action,
"approvedMaterialIds": body.approved_material_ids,
"extraKeywords": body.extra_keywords,
"editedOutline": body.edited_outline,
"outlineFeedback": body.outline_feedback,
"userRevisionNotes": body.user_revision_notes,
"overrideVerdict": body.override_verdict,
}
asyncio.create_task(
_stream_graph_segment(session_id, Command(resume=intervention), config, AI_WRITING_GRAPH)
)
return {"message": "已提交,流程恢复中"}
@router.get("/{session_id}/state")
async def get_state(session_id: str):
"""查询当前流程状态快照。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
config = _make_config(session_id)
snapshot = await AI_WRITING_GRAPH.aget_state(config)
if not snapshot or not snapshot.values:
raise HTTPException(status_code=404, detail="Session not found")
return snapshot.values
# ── 内部辅助 ──────────────────────────────────────────────────────────────────
async def _stream_graph_segment(
session_id: str,
input_or_command,
config: dict,
graph,
):
"""运行一个 Graph 片段(初始运行或 resume),将输出推入队列。
遇到 interrupt 时停止(不关闭队列),等待下次 resume。
Graph 结束时关闭队列(放入 None 哨兵)。
"""
queue = _event_queues.get(session_id)
if queue is None:
return
reached_end = False
try:
async for chunk in graph.astream(input_or_command, config=config, stream_mode="updates"):
node_name = next(iter(chunk))
node_output = chunk[node_name]
# interrupt() 触发
if node_name == "__interrupt__":
# node_output 是 (Interrupt(...),) 元组
interrupt_list = node_output if isinstance(node_output, (list, tuple)) else [node_output]
for intr in interrupt_list:
interrupt_data = intr.value if hasattr(intr, "value") else intr
await queue.put(("await_user", interrupt_data))
return # 等待 resume,不关闭队列
if not isinstance(node_output, dict):
continue
# 推送 progress_events
for evt in node_output.get("progress_events") or []:
await queue.put((_event_type(evt), {**evt, "session_id": session_id}))
# 素材就绪
if pkg := node_output.get("material_package"):
await queue.put(("materials_ready", {
"session_id": session_id,
"keywords": pkg.get("keywords", []),
"materials": pkg.get("materials", []),
"summary": pkg.get("summary", ""),
}))
# 大纲就绪
if outline := node_output.get("current_outline"):
await queue.put(("outline_ready", {"session_id": session_id, "outline": outline}))
# 草稿就绪
if draft := node_output.get("current_draft"):
await queue.put(("draft_ready", {
"session_id": session_id,
"title": draft.get("title", ""),
"markdown": draft.get("full_markdown", ""),
"revision_count": draft.get("revision_count", 0),
}))
# 编辑审核就绪
if review := node_output.get("review_result"):
await queue.put(("review_ready", {"session_id": session_id, "review_result": review}))
# 状态 done:Graph 结束
if node_output.get("status") == "done":
reached_end = True
# astream 正常结束 = Graph 执行到 END
reached_end = True
await queue.put(("done", {"session_id": session_id, "message": "写作完成"}))
except Exception as e:
logger.exception("ai_writing graph segment failed: session=%s", session_id)
await queue.put(("error", {"session_id": session_id, "message": str(e)}))
reached_end = True
finally:
if reached_end:
await queue.put(None) # 哨兵,通知 SSE 生成器关闭
_event_queues.pop(session_id, None)
_session_model_names.pop(session_id, None)
def _event_type(evt: dict) -> str:
t = evt.get("type", "progress")
# 过滤掉已在上方单独推送的事件类型,避免重复
if t in ("materials_ready", "outline_ready", "draft_ready", "review_ready", "await_user"):
return "step_done"
return t
async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]:
"""从队列消费事件,推送为 SSE 帧。"""
# 等待队列创建(最多 5 秒)
for _ in range(50):
if session_id in _event_queues:
break
await asyncio.sleep(0.1)
else:
yield _sse("error", {"message": "Session not found"})
return
queue = _event_queues[session_id]
yield _sse("connected", {"session_id": session_id})
while True:
item = await queue.get()
if item is None:
break
event_type, data = item
yield _sse(event_type, data)
await asyncio.sleep(0)
@@ -0,0 +1,3 @@
from .graph import AI_WRITING_GRAPH, build_ai_writing_graph
__all__ = ["AI_WRITING_GRAPH", "build_ai_writing_graph"]
@@ -0,0 +1,231 @@
"""AI 写作 LangGraph Graph,含 4 个用户干预点。"""
from __future__ import annotations
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, StateGraph
from langgraph.types import interrupt
from .state import AIWritingState
from .nodes.intent_parser import intent_parser_node
from .nodes.researcher import researcher_node
from .nodes.writer_outline import writer_outline_node
from .nodes.writer_draft import writer_draft_node
from .nodes.editor import editor_node
# ── 干预节点 ──────────────────────────────────────────────────────────────────
async def pause_material_confirm(state: AIWritingState) -> dict:
"""PAUSE-1:素材确认"""
pkg = state["material_package"]
user_input: dict = interrupt({
"pause_point": "material_confirm",
"message": "素材收集专家已完成素材搜集,请确认素材后继续。",
"materials": pkg["materials"],
"keywords": pkg["keywords"],
"summary": pkg["summary"],
})
action = user_input.get("action", "confirm")
if action == "confirm":
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
filtered = [m for m in pkg["materials"] if m["id"] in approved_ids]
updated_pkg = {**pkg, "materials": filtered}
return {
"material_package": updated_pkg,
"last_user_intervention": user_input,
"status": "writing_outline",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认素材,开始规划大纲"}],
}
# re_search:重新搜索
extra_kws = user_input.get("extraKeywords") or []
return {
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws},
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "重新搜索中..."}],
}
def _normalize_outline(outline: dict) -> dict:
"""将前端回传的 camelCase 大纲统一为后端 snake_case 格式。"""
if not outline:
return outline
sections = []
for s in outline.get("sections", []):
sections.append({
"section_title": s.get("section_title") or s.get("sectionTitle", ""),
"key_points": s.get("key_points") or s.get("keyPoints", []),
"material_ids": s.get("material_ids") or s.get("materialIds", []),
})
return {"title": outline.get("title", ""), "sections": sections}
async def pause_outline_confirm(state: AIWritingState) -> dict:
"""PAUSE-2:大纲确认"""
user_input: dict = interrupt({
"pause_point": "outline_confirm",
"message": "作家已完成大纲规划,请确认或编辑大纲后继续。",
"outline": state["current_outline"],
})
action = user_input.get("action", "confirm")
if action == "confirm":
raw_outline = user_input.get("editedOutline") or state["current_outline"]
edited = _normalize_outline(raw_outline) if raw_outline else state["current_outline"]
return {
"current_outline": edited,
"last_user_intervention": user_input,
"status": "writing_draft",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认大纲,开始写作"}],
}
# re_outline
feedback = user_input.get("outlineFeedback", "")
return {
"last_user_intervention": {**user_input, "_feedback": feedback},
"status": "writing_outline",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": f"重新规划大纲({feedback[:30]})"}],
}
async def pause_draft_confirm(state: AIWritingState) -> dict:
"""PAUSE-3:草稿确认"""
draft = state["current_draft"]
user_input: dict = interrupt({
"pause_point": "draft_confirm",
"message": "草稿已完成,请确认或给出修改意见。",
"draft_title": draft["title"],
"revision_count": draft["revision_count"],
"review_result": state.get("review_result"),
})
action = user_input.get("action", "to_editor")
if action == "finalize":
return {
"last_user_intervention": user_input,
"status": "done",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户确认定稿"}],
}
if action == "user_revise":
notes = user_input.get("userRevisionNotes", "")
return {
"last_user_intervention": {**user_input, "_user_notes": notes},
"revision_count": state.get("revision_count", 0) + 1,
"status": "revising",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": f"按用户意见重写({notes[:30]})"}],
}
# to_editor
return {
"last_user_intervention": user_input,
"status": "reviewing",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "提交编辑审核"}],
}
async def pause_review_confirm(state: AIWritingState) -> dict:
"""PAUSE-4:编辑打回确认"""
user_input: dict = interrupt({
"pause_point": "review_confirm",
"message": "编辑认为文章需要修改,请决定下一步操作。",
"review_result": state["review_result"],
"revision_count": state.get("revision_count", 0),
"max_revisions": state.get("max_revisions", 3),
})
if user_input.get("overrideVerdict") or user_input.get("action") == "force_finalize":
return {
"last_user_intervention": user_input,
"status": "done",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户强制定稿"}],
}
action = user_input.get("action", "accept_review")
extra_notes = user_input.get("userRevisionNotes", "")
return {
"last_user_intervention": {**user_input, "_extra_notes": extra_notes, "action": action},
"revision_count": state.get("revision_count", 0) + 1,
"status": "revising",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "按编辑意见重写"}],
}
# ── 条件路由 ──────────────────────────────────────────────────────────────────
def after_material_pause(state: AIWritingState) -> str:
return "researcher" if state["status"] == "researching" else "writer_outline"
def after_outline_pause(state: AIWritingState) -> str:
return "writer_outline" if state["status"] == "writing_outline" else "writer_draft"
def after_draft_pause(state: AIWritingState) -> str:
status = state["status"]
if status == "done":
return "end"
if status == "revising":
return "writer_draft"
return "editor"
def after_review(state: AIWritingState) -> str:
review = state.get("review_result") or {}
if review.get("verdict") == "pass":
return "pause_draft"
rev_count = state.get("revision_count", 0)
max_rev = state.get("max_revisions", 3)
if rev_count >= max_rev:
return "pause_draft" # 超出上限,强制到草稿确认
return "pause_review"
def after_review_pause(state: AIWritingState) -> str:
return "end" if state["status"] == "done" else "writer_draft"
# ── 组装 Graph ────────────────────────────────────────────────────────────────
def build_ai_writing_graph():
g = StateGraph(AIWritingState)
g.add_node("intent_parser", intent_parser_node)
g.add_node("researcher", researcher_node)
g.add_node("pause_material", pause_material_confirm)
g.add_node("writer_outline", writer_outline_node)
g.add_node("pause_outline", pause_outline_confirm)
g.add_node("writer_draft", writer_draft_node)
g.add_node("pause_draft", pause_draft_confirm)
g.add_node("editor", editor_node)
g.add_node("pause_review", pause_review_confirm)
g.set_entry_point("intent_parser")
g.add_edge("intent_parser", "researcher")
g.add_edge("researcher", "pause_material")
g.add_conditional_edges("pause_material", after_material_pause,
{"researcher": "researcher", "writer_outline": "writer_outline"})
g.add_edge("writer_outline", "pause_outline")
g.add_conditional_edges("pause_outline", after_outline_pause,
{"writer_outline": "writer_outline", "writer_draft": "writer_draft"})
g.add_edge("writer_draft", "pause_draft")
g.add_conditional_edges("pause_draft", after_draft_pause,
{"end": END, "writer_draft": "writer_draft", "editor": "editor"})
g.add_conditional_edges("editor", after_review,
{"pause_draft": "pause_draft", "pause_review": "pause_review"})
g.add_conditional_edges("pause_review", after_review_pause,
{"end": END, "writer_draft": "writer_draft"})
checkpointer = MemorySaver()
return g.compile(checkpointer=checkpointer)
AI_WRITING_GRAPH = build_ai_writing_graph()
@@ -0,0 +1,146 @@
"""节点共用工具:JSON 解析容错、LLM 创建。
弱模型经常输出不规范 JSON(中文标点、结尾多余逗号、被 markdown 包裹、
夹带解释文字、嵌套对象)。parse_json 用多级降级策略尽量抢救出结构化结果。
"""
from __future__ import annotations
import json
import logging
import re
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
logger = logging.getLogger(__name__)
def get_model(config: RunnableConfig):
from deerflow.models import create_chat_model
model_name = config.get("configurable", {}).get("model_name", "") or None
return create_chat_model(name=model_name)
# 弱模型常把结构性标点写成中文全角符号,仅在常规解析失败后作为修复手段
_FULLWIDTH_FIX = {
"“": '"', # 左双引号 “
"”": '"', # 右双引号 ”
"‘": "'", # 左单引号 ‘
"’": "'", # 右单引号 ’
":": ":", # 全角冒号 :
",": ",", # 全角逗号 ,
"(": "(", # 全角左括号 (
")": ")", # 全角右括号 )
"{": "{", # 全角左花括号 {
"}": "}", # 全角右花括号 }
"[": "[", # 全角左方括号 [
"]": "]", # 全角右方括号 ]
"【": "[", # 黑方括号 【
"】": "]", # 黑方括号 】
}
def _extract_balanced(text: str) -> str | None:
"""从文本中提取第一个括号平衡的 {...} 块(支持嵌套,忽略字符串内的括号)。"""
start = text.find("{")
if start == -1:
return None
depth = 0
in_str = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if ch == '"':
in_str = not in_str
continue
if in_str:
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[start:i + 1]
return None
def _repair_json(text: str) -> str:
"""尽力修复弱模型常见的 JSON 错误:全角标点、结尾多余逗号。"""
for bad, good in _FULLWIDTH_FIX.items():
text = text.replace(bad, good)
# 去掉对象/数组结尾多余的逗号: ,} 或 ,]
text = re.sub(r",(\s*[}\]])", r"\1", text)
return text
def _try_load(text: str) -> dict | None:
try:
result = json.loads(text)
except Exception:
return None
return result if isinstance(result, dict) else None
def parse_json(text: str, fallback: dict) -> dict:
"""从 LLM 响应中提取 JSON 对象,多级降级,失败时返回 fallback。"""
if not text or not text.strip():
return fallback
text = text.strip()
# 1. 直接解析
if (r := _try_load(text)) is not None:
return r
# 2. markdown 代码块内容
m = re.search(r"```(?:json)?\s*(.+?)\s*```", text, re.DOTALL)
if m:
fenced = m.group(1).strip()
if (r := _try_load(fenced)) is not None:
return r
text = fenced # 用代码块内容继续后续步骤
# 3. 平衡括号提取(处理夹带解释文字、嵌套对象的情况)
candidate = _extract_balanced(text)
if candidate:
if (r := _try_load(candidate)) is not None:
return r
# 4. 修复后再试
if (r := _try_load(_repair_json(candidate))) is not None:
return r
# 5. 整体修复后兜底
if (r := _try_load(_repair_json(text))) is not None:
return r
logger.warning("JSON parse failed, using fallback. raw=%r", text[:200])
return fallback
def _content_to_text(content) -> str:
"""将 LLM 响应内容统一为字符串(部分模型返回结构化 content 块列表)。"""
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
)
return str(content)
async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig) -> dict:
"""调用 LLM,解析 JSON 输出,失败用 fallback。"""
model = get_model(config)
messages = [SystemMessage(content=system), HumanMessage(content=user)]
try:
resp = await model.ainvoke(messages)
return parse_json(_content_to_text(resp.content), fallback)
except Exception as e:
logger.error("llm_json failed: %s", e, exc_info=True)
return {**fallback, "_llm_error": str(e)}
@@ -0,0 +1,107 @@
"""编辑节点:三项独立 LLM 审查(事实/逻辑/语言)。"""
from __future__ import annotations
import asyncio
import logging
from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, ReviewResult, ReviewIssue
from ..prompts.editor_prompts import (
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
)
from ._utils import llm_json
logger = logging.getLogger(__name__)
# 弱模型产出质量有限,阈值过高会导致编辑反复打回直至触发修改上限。
# 默认 75,可通过 ai_writing_config.pass_threshold 按实测调整。
_DEFAULT_PASS_THRESHOLD = 75
def _materials_summary(pkg: dict) -> str:
lines = []
for m in (pkg.get("materials") or [])[:6]:
lines.append(f"[{m['id']}] {m['title']}: {m['content'][:200]}")
return "\n".join(lines) or "(无素材)"
def _parse_issues(raw_issues: list, category: str) -> list[ReviewIssue]:
issues = []
for item in (raw_issues or []):
issues.append(ReviewIssue(
category=category,
severity=item.get("severity", "minor"),
location=item.get("location", ""),
issue=item.get("issue", ""),
suggestion=item.get("suggestion", ""),
))
return issues
async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
draft = state.get("current_draft")
if not draft:
return {
"status": "error",
"progress_events": [{"type": "error", "agent_name": "编辑", "message": "草稿为空,无法审核"}],
}
article = draft["full_markdown"]
pkg = state.get("material_package") or {}
mats = _materials_summary(pkg)
ai_writing_config = config.get("configurable", {}).get("ai_writing_config", {}) or {}
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
# 三项独立审查并发执行
fact_task = llm_json(EDITOR_SYSTEM, FACT_REVIEW_USER.format(materials_summary=mats, article=article), {"score": 80, "issues": []}, config)
logic_task = llm_json(EDITOR_SYSTEM, LOGIC_REVIEW_USER.format(article=article), {"score": 80, "issues": []}, config)
lang_task = llm_json(EDITOR_SYSTEM, LANGUAGE_REVIEW_USER.format(article=article), {"score": 80, "issues": []}, config)
fact_res, logic_res, lang_res = await asyncio.gather(fact_task, logic_task, lang_task, return_exceptions=True)
def safe(res, default):
return default if isinstance(res, Exception) else res
fact_res = safe(fact_res, {"score": 80, "issues": []})
logic_res = safe(logic_res, {"score": 80, "issues": []})
lang_res = safe(lang_res, {"score": 80, "issues": []})
fact_score = int(fact_res.get("score", 80))
logic_score = int(logic_res.get("score", 80))
lang_score = int(lang_res.get("score", 80))
overall_score = (fact_score + logic_score + lang_score) // 3
all_issues: list[ReviewIssue] = (
_parse_issues(fact_res.get("issues", []), "fact") +
_parse_issues(logic_res.get("issues", []), "logic") +
_parse_issues(lang_res.get("issues", []), "language")
)
# major issues first
all_issues.sort(key=lambda x: (0 if x["severity"] == "major" else 1))
verdict = "pass" if overall_score >= pass_threshold else "reject"
review = ReviewResult(
verdict=verdict,
fact_score=fact_score,
logic_score=logic_score,
language_score=lang_score,
overall_score=overall_score,
pass_threshold=pass_threshold,
revision_notes=all_issues,
)
return {
"review_result": review,
"progress_events": [
{"type": "step_start", "agent_name": "编辑", "message": "开始审核"},
{
"type": "review_ready",
"agent_name": "编辑",
"message": f"审核完成,综合得分 {overall_score},结论:{'通过' if verdict == 'pass' else '打回修改'}",
"review_result": review,
},
],
}
@@ -0,0 +1,60 @@
"""意图解析节点:将用户自然语言意图结构化为写作配置。"""
from __future__ import annotations
from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState
from ._utils import llm_json
_SYSTEM = """你是一名写作助手。
你的任务:分析用户的写作意图,提取结构化的写作配置。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- article_type 只能是 news / analysis / feature / report 之一。
【完整示例】
输入意图:帮我写一篇面向行业专家的深度文章,分析今年新能源汽车出口为什么大涨,大概两千字
正确输出:
{"article_type": "feature", "target_audience": "行业专家", "word_count_target": 2000, "refined_intent": "分析今年新能源汽车出口高速增长的原因"}"""
_USER = """用户写作意图:{user_intent}
请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值):
{{
"article_type": "news|analysis|feature|report 之一",
"target_audience": "目标读者描述",
"word_count_target": 800,
"refined_intent": "精炼后的写作意图(一句话)"
}}"""
async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict:
result = await llm_json(
_SYSTEM,
_USER.format(user_intent=state["user_intent"]),
fallback={
"article_type": state.get("article_type", "analysis"),
"target_audience": state.get("target_audience", "普通读者"),
"word_count_target": state.get("word_count_target", 800),
"refined_intent": state["user_intent"],
},
config=config,
)
updates: dict = {
"status": "researching",
"progress_events": [{"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}],
}
if result.get("article_type"):
updates["article_type"] = result["article_type"]
if result.get("target_audience"):
updates["target_audience"] = result["target_audience"]
if result.get("word_count_target"):
updates["word_count_target"] = int(result["word_count_target"])
if result.get("refined_intent"):
updates["user_intent"] = result["refined_intent"]
return updates
@@ -0,0 +1,119 @@
"""素材收集专家节点:关键词提取 → 并发搜索 → 相关度排序。"""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import List
from langchain_core.runnables import RunnableConfig
from ..search import get_search_provider, SearchResult
from ..state import AIWritingState, Material, MaterialPackage
from ..prompts.researcher_prompts import (
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER,
)
from ._utils import llm_json
logger = logging.getLogger(__name__)
_MAX_MATERIALS = 12
async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict:
intent = state["user_intent"]
article_type = state.get("article_type", "analysis")
target_audience = state.get("target_audience", "普通读者")
# 检查是否是补充搜索(PAUSE-1 re_search action)
intervention = state.get("last_user_intervention") or {}
extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or []
# Step 1: 提取关键词
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
),
fallback={"keywords": [intent]},
config=config,
)
keywords: list[str] = kw_result.get("keywords", [intent])
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
# Step 2: 并发搜索
ai_writing_config = config.get("configurable", {}).get("ai_writing_config", {})
provider = get_search_provider(ai_writing_config)
search_tasks = [provider.search(kw, max_results=3) for kw in keywords[:6]]
all_results_nested: list[list[SearchResult]] = await asyncio.gather(*search_tasks, return_exceptions=True)
# 合并去重
seen_urls: set[str] = set()
raw_materials: list[Material] = []
existing_pkg = state.get("material_package")
if existing_pkg and intervention.get("action") != "re_search":
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
for results in all_results_nested:
if isinstance(results, Exception):
logger.warning("search failed: %s", results)
continue
for r in results:
if r.url and r.url in seen_urls:
continue
seen_urls.add(r.url)
raw_materials.append(Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
source=r.source,
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
# Step 3: 相关度排序(如素材较多,让 LLM 排序)
if len(raw_materials) > _MAX_MATERIALS:
materials_list = "\n".join(
f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}"
for m in raw_materials
)
rank_result = await llm_json(
RANK_MATERIALS_SYSTEM,
RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list),
fallback={"ranked_ids": [m["id"] for m in raw_materials[:_MAX_MATERIALS]], "summary": f"共搜索到{len(raw_materials)}条素材"},
config=config,
)
ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials])
id_to_mat = {m["id"]: m for m in raw_materials}
sorted_materials = [id_to_mat[mid] for mid in ranked_ids if mid in id_to_mat]
# 加入未被排序的素材
sorted_ids_set = set(ranked_ids)
for m in raw_materials:
if m["id"] not in sorted_ids_set:
sorted_materials.append(m)
materials = sorted_materials[:_MAX_MATERIALS]
summary = rank_result.get("summary", f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条")
else:
materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:_MAX_MATERIALS]
summary = f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条"
pkg = MaterialPackage(keywords=keywords, materials=materials, summary=summary)
return {
"material_package": pkg,
"status": "awaiting_material_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "素材收集专家", "message": f"搜索关键词:{', '.join(keywords[:3])}"},
{"type": "materials_ready", "agent_name": "素材收集专家", "message": summary},
],
}
@@ -0,0 +1,123 @@
"""作家节点 Phase 2:按大纲逐段写正文。"""
from __future__ import annotations
import logging
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, DraftArticle
from ..prompts.writer_prompts import DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER
from ._utils import get_model, _content_to_text
logger = logging.getLogger(__name__)
def _get_section_materials(material_ids: list[str], pkg: dict) -> str:
mat_map = {m["id"]: m for m in pkg.get("materials", [])}
lines = []
for mid in material_ids:
if mid in mat_map:
m = mat_map[mid]
lines.append(f"[{mid}] {m['title']}: {m['content'][:300]}")
if not lines:
# 没有指定素材时,使用前两条
for m in pkg.get("materials", [])[:2]:
lines.append(f"[{m['id']}] {m['title']}: {m['content'][:300]}")
return "\n".join(lines) if lines else "(无相关素材)"
async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> dict:
outline = state.get("current_outline")
if not outline:
return {
"status": "error",
"progress_events": [{"type": "error", "agent_name": "作家", "message": "大纲为空,无法写作"}],
}
pkg = state.get("material_package") or {"keywords": [], "materials": [], "summary": ""}
intervention = state.get("last_user_intervention") or {}
revision_count = state.get("revision_count", 0)
# 修改意见(来自用户或编辑)
revision_notes = ""
if intervention.get("action") == "user_revise":
notes = intervention.get("_user_notes") or intervention.get("user_revision_notes", "")
if notes:
revision_notes = f"\n用户修改意见:{notes}"
elif intervention.get("action") in ("accept_review", "custom_revise"):
review = state.get("review_result") or {}
issues = review.get("revision_notes", [])
extra = intervention.get("_extra_notes") or intervention.get("user_revision_notes", "")
parts = []
for iss in issues[:5]:
parts.append(f"- [{iss.get('severity','minor')}] {iss.get('location','')}: {iss.get('suggestion','')}")
if extra:
parts.append(f"- 用户补充:{extra}")
if parts:
revision_notes = "\n修改要求:\n" + "\n".join(parts)
word_count_target = state.get("word_count_target", 800)
sections = outline.get("sections", [])
words_per_section = max(100, word_count_target // max(len(sections), 1))
model = get_model(config)
section_texts: list[str] = []
for i, section in enumerate(sections):
# 兼容后端 snake_case 和前端编辑后回传的 camelCase
sec_title = section.get("section_title") or section.get("sectionTitle", f"章节{i+1}")
key_points = section.get("key_points") or section.get("keyPoints") or []
mat_ids = section.get("material_ids") or section.get("materialIds") or []
section_materials = _get_section_materials(mat_ids, pkg)
key_points_str = "\n".join(f"- {p}" for p in key_points)
user_msg = DRAFT_SECTION_USER.format(
article_title=outline.get("title", "文章"),
section_title=sec_title,
key_points=key_points_str,
section_materials=section_materials,
revision_section=revision_notes,
target_words=words_per_section,
)
try:
resp = await model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
])
# 部分模型(如 DeepSeek thinking 模式)返回列表结构的 content,统一转为字符串
text = _content_to_text(resp.content).strip()
section_texts.append(f"## {sec_title}\n\n{text}")
except Exception as e:
logger.error("draft section [%s] failed: %s", sec_title, e, exc_info=True)
section_texts.append(f"## {sec_title}\n\n(内容生成失败,请重试)")
full_markdown = f"# {outline['title']}\n\n" + "\n\n".join(section_texts)
word_count = len([c for c in full_markdown if '一' <= c <= '鿿'])
draft = DraftArticle(
title=outline["title"],
outline=outline,
full_markdown=full_markdown,
word_count=word_count,
revision_count=revision_count,
)
return {
"current_draft": draft,
"drafts": [draft],
"status": "awaiting_draft_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"},
{
"type": "draft_ready",
"agent_name": "作家",
"message": f"草稿完成,共 {word_count} 字",
"draft_title": outline["title"],
"draft_markdown": full_markdown,
"revision_count": revision_count,
},
],
}
@@ -0,0 +1,83 @@
"""作家节点 Phase 1:生成文章大纲。"""
from __future__ import annotations
from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, Outline, OutlineSection
from ..prompts.writer_prompts import OUTLINE_SYSTEM, OUTLINE_USER
from ._utils import llm_json
# 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 token 溢出。
_MAX_OUTLINE_MATERIALS = 12
_MATERIAL_SNIPPET_CHARS = 150
def _materials_summary(pkg: dict) -> str:
lines = []
for m in pkg.get("materials", [])[:_MAX_OUTLINE_MATERIALS]:
lines.append(f"[{m['id']}] {m['title']} | {m['source']} | {m['content'][:_MATERIAL_SNIPPET_CHARS]}")
return "\n".join(lines) or "(无素材)"
def _parse_outline(raw: dict) -> Outline:
sections = []
for s in raw.get("sections", []):
sections.append(OutlineSection(
section_title=s.get("section_title", ""),
key_points=s.get("key_points", []),
material_ids=s.get("material_ids", []),
))
if not sections:
sections = [OutlineSection(section_title="正文", key_points=["主要内容"], material_ids=[])]
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> dict:
pkg = state.get("material_package") or {"keywords": [], "materials": [], "summary": ""}
intervention = state.get("last_user_intervention") or {}
feedback_section = ""
if intervention.get("action") == "re_outline" and intervention.get("_feedback"):
feedback_section = f"\n重新规划要求:{intervention['_feedback']}"
elif intervention.get("outline_feedback"):
feedback_section = f"\n重新规划要求:{intervention['outline_feedback']}"
result = await llm_json(
OUTLINE_SYSTEM,
OUTLINE_USER.format(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
feedback_section=feedback_section,
),
fallback={
"title": "未命名文章",
"sections": [
{"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []},
{"section_title": "正文", "key_points": ["核心内容"], "material_ids": []},
{"section_title": "结论", "key_points": ["总结"], "material_ids": []},
],
},
config=config,
)
if llm_err := result.pop("_llm_error", None):
return {
"status": "error",
"progress_events": [{"type": "error", "agent_name": "作家", "message": f"大纲生成失败:{llm_err}"}],
}
outline = _parse_outline(result)
section_count = len(outline["sections"])
return {
"current_outline": outline,
"status": "awaiting_outline_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": "开始规划大纲"},
{"type": "outline_ready", "agent_name": "作家", "message": f"大纲已生成,共 {section_count} 个章节"},
],
}
@@ -0,0 +1,52 @@
EDITOR_SYSTEM = """你是一名资深主编(编辑),标准极高,要求严格。
你的目标:审查文章质量,给出客观评分和具体修改意见。
你的工作背景:你在一家权威媒体担任主编十五年,见过各种质量的文章。
你只能做以下事情:
1. 从指定维度审查文章
2. 给出该维度的评分(0-100 的整数)和具体问题列表
你不能改写文章,只做当前指定维度的审查。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- score 是 0-100 的整数。
- 每个问题的 severity 必须是 "major"(必须修改)或 "minor"(建议修改)。
- 文章在该维度没有问题时,issues 填空数组 []。
【完整示例】
正确输出:
{
"score": 82,
"issues": [
{"severity": "major", "location": "第二段", "issue": "引用的增长数据没有素材支撑", "suggestion": "补充素材 m002 中的具体数据,并注明来源"},
{"severity": "minor", "location": "结尾段", "issue": "结论略显仓促", "suggestion": "增加一句对未来行业趋势的判断"}
]
}"""
FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。
判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。
参考素材:
{materials_summary}
待审文章:
{article}
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。
判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。
待审文章:
{article}
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
LANGUAGE_REVIEW_USER = """请审查以下文章的【语言文字】维度。
判断标准:语言是否流畅,用词是否准确,有无错别字或病句。
待审文章:
{article}
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
@@ -0,0 +1,49 @@
EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专家)。
你的目标:为写作任务提取搜索关键词。
你的工作背景:你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。
你只能做以下事情:
1. 从写作意图中提取 3-6 个搜索关键词
你不能写文章,不能评价内容好坏,只做关键词提取。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 ",不要用中文引号 “”。
- 关键词要具体、可搜索,避免过于宽泛的词。
【完整示例】
输入意图:写一篇分析今年新能源汽车出口增长的文章
正确输出:
{"keywords": ["新能源汽车出口", "汽车出口数据", "新能源汽车海外市场", "电动车出口增长", "汽车出口政策"]}"""
EXTRACT_KEYWORDS_USER = """写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
请提取 3-6 个搜索关键词,用于搜集相关素材。只输出 JSON:
{{"keywords": ["关键词1", "关键词2"]}}"""
RANK_MATERIALS_SYSTEM = """你是一名专业情报研究员(素材收集专家)。
你的目标:对搜索结果按相关度排序和筛选。
你的工作背景:你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。
你只能做以下事情:
1. 从提供的素材列表中,选出与写作意图最相关的素材ID,按相关度从高到低排序
你不能写文章,只做素材筛选排序。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- ranked_ids 中的 ID 必须来自素材列表,不要编造 ID。
【完整示例】
正确输出:
{"ranked_ids": ["m003", "m001", "m005"], "summary": "共搜索到 8 条素材,精选 5 条最相关的"}"""
RANK_MATERIALS_USER = """写作意图:{user_intent}
素材列表(每条含ID和摘要):
{materials_list}
请选出最相关的素材ID(最多8条),按相关度从高到低排序。只输出 JSON:
{{"ranked_ids": ["id1", "id2"], "summary": "共搜索到X条素材,精选Y条"}}"""
@@ -0,0 +1,57 @@
OUTLINE_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据素材和意图,规划清晰的文章大纲。
你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
你只能做以下事情:
1. 分析素材,提取核心观点
2. 规划文章章节结构和每节要点
你不能写正文,只写大纲。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 ",不要用中文引号 “”。
- 规划 3-5 个章节;material_ids 中的 ID 必须来自给定素材,没有合适素材时填 []。
【完整示例】
正确输出:
{
"title": "新能源汽车出口为何高速增长",
"sections": [
{"section_title": "引言:出口数据创新高", "key_points": ["今年出口量同比增长", "在全球市场的排名变化"], "material_ids": ["m001"]},
{"section_title": "增长背后的三大动因", "key_points": ["产业链成本优势", "海外政策利好", "产品竞争力提升"], "material_ids": ["m002", "m003"]},
{"section_title": "结论与展望", "key_points": ["未来增长面临的挑战", "行业趋势判断"], "material_ids": ["m004"]}
]
}"""
OUTLINE_USER = """写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
目标字数:{word_count_target}字{feedback_section}
可用素材摘要:
{materials_summary}
请规划文章大纲(3-5个章节),只输出 JSON 格式。"""
DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据大纲和素材,写出一段高质量的文章内容。
你的工作背景:你有十年内容创作经验,文字流畅,善于用事实说话。
你只能做以下事情:
1. 根据给定的章节要点和素材写这一段内容
2. 观点必须来自素材,不编造数据
你不能修改大纲,不能写其他章节,只写当前这一段。
【输出要求】
- 直接返回这一段的正文文字,不要 JSON,不要章节标题,不要解释。
- 不要写"以下是正文""本段内容如下"之类的开场白。
- 段落要连贯成文,不要罗列要点。"""
DRAFT_SECTION_USER = """文章标题:{article_title}
当前章节:{section_title}
章节要点:{key_points}
相关素材:
{section_materials}
{revision_section}
请写出这一章节的正文内容({target_words}字左右)。"""
@@ -0,0 +1,63 @@
from .base import BaseSearchProvider, SearchResult
from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider
from .demo_search import DemoSearchProvider
from .duckduckgo_search import DuckDuckGoSearchProvider
def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
"""
config.yaml 中配置:
ai_writing:
search_provider: internet # internet(默认,DDG) / intranet / tavily / demo
intranet_search_url: http://...
intranet_search_api_key: ""
intranet_field_map:
content: "abstract"
source: "system_name"
ddg_region: "cn-zh" # DuckDuckGo 区域,默认 cn-zh
internet 模式:DuckDuckGo 为主搜索(免 API Key),失败时 fallback 到 demo 演示素材。
"""
cfg = ai_writing_config or {}
provider = cfg.get("search_provider", "internet")
if provider == "intranet":
return IntranetSearchProvider(
base_url=cfg.get("intranet_search_url", ""),
api_key=cfg.get("intranet_search_api_key", ""),
field_map=cfg.get("intranet_field_map", {}),
)
if provider == "tavily":
return _FallbackSearchProvider(InternetSearchProvider(), DemoSearchProvider())
if provider == "demo":
return DemoSearchProvider()
# 默认 internet 模式:DuckDuckGo + demo fallback
region = cfg.get("ddg_region", "cn-zh")
return _FallbackSearchProvider(DuckDuckGoSearchProvider(region=region), DemoSearchProvider())
class _FallbackSearchProvider(BaseSearchProvider):
"""当主 provider 返回空结果时自动切换到 fallback provider。"""
def __init__(self, primary: BaseSearchProvider, fallback: BaseSearchProvider):
self._primary = primary
self._fallback = fallback
async def search(self, query: str, max_results: int = 5, **kwargs):
results = await self._primary.search(query, max_results=max_results, **kwargs)
if not results:
results = await self._fallback.search(query, max_results=max_results, **kwargs)
return results
async def health_check(self) -> bool:
return await self._primary.health_check() or await self._fallback.health_check()
__all__ = [
"BaseSearchProvider", "SearchResult",
"InternetSearchProvider", "IntranetSearchProvider",
"DemoSearchProvider", "DuckDuckGoSearchProvider",
"get_search_provider",
]
@@ -0,0 +1,40 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List
@dataclass
class SearchResult:
"""统一搜索结果格式 —— 内外网共用"""
title: str
content: str
source: str
published_at: str
url: str = ""
relevance_score: float = 0.0
source_type: str = "internet"
recUuid: str = ""
extra_metadata: dict = field(default_factory=dict)
class BaseSearchProvider(ABC):
"""搜索提供者抽象基类。
对接内网步骤:
1. 继承此类,创建 IntranetSearchProvider
2. 实现 search() —— 调用内网 API,将响应映射到 SearchResult
3. 在 config.yaml 中设置 ai_writing.search_provider: intranet
4. 无需修改任何 Agent 代码
"""
@abstractmethod
async def search(
self,
query: str,
max_results: int = 5,
date_from: str = "",
date_to: str = "",
) -> List[SearchResult]: ...
@abstractmethod
async def health_check(self) -> bool: ...
@@ -0,0 +1,183 @@
"""离线演示搜索提供者 —— 当无法访问互联网/内网时返回模拟素材,供测试完整写作流程。"""
from __future__ import annotations
import hashlib
from typing import List
from .base import BaseSearchProvider, SearchResult
# 演示素材库:按话题分类,用于关键词模糊匹配
_DEMO_CORPUS = [
{
"keywords": ["新能源", "电动车", "汽车", "ev", "electric"],
"items": [
{
"title": "2025年新能源汽车市场规模突破万亿,渗透率达45%",
"content": "据中国汽车工业协会数据,2025年前三季度新能源汽车累计销量超过850万辆,同比增长32%,市场渗透率达到45.3%。比亚迪、吉利、长城等头部企业份额持续集中,纯电与插混比例趋近5:5。",
"source": "中国汽车工业协会",
"published_at": "2025-10-15",
"relevance_score": 0.95,
},
{
"title": "固态电池量产进入倒计时:多家车企宣布2026年装车计划",
"content": "宁德时代、比亚迪、丰田同步推进固态电池量产。固态电池能量密度可达400Wh/kg,较现有液态锂电池提升60%,续航里程有望突破1000公里。成本仍是主要瓶颈,预计量产初期每度电成本高出30%。",
"source": "电池中国网",
"published_at": "2025-09-20",
"relevance_score": 0.88,
},
{
"title": "充电基础设施缺口分析:农村地区桩车比仅1:12",
"content": "截至2025年8月,全国充电桩保有量达1120万根,城市地区桩车比约1:2.8,但农村及县域地区桩车比仅1:12,制约新能源汽车下沉市场。国家电网计划2026年前新增农村快充站3万座。",
"source": "中国充电联盟",
"published_at": "2025-08-30",
"relevance_score": 0.82,
},
],
},
{
"keywords": ["人工智能", "ai", "大模型", "llm", "机器学习", "深度学习"],
"items": [
{
"title": "国内大模型进入「百亿参数普惠化」阶段,推理成本下降90%",
"content": "2025年第三季度,国内主要大模型服务商将百亿参数模型推理价格降至每百万token不足1元,较2024年同期下降90%。开源生态加速,Qwen3、DeepSeek-V3等模型在多项基准测试中超越GPT-4o。",
"source": "中国信通院",
"published_at": "2025-10-08",
"relevance_score": 0.93,
},
{
"title": "企业AI应用落地白皮书:制造业ROI最高达300%",
"content": "调研覆盖500家企业的白皮书显示,AI在质检、排产、预测性维护三个场景ROI最高,部分制造企业ROI超300%。阻碍因素:数据孤岛(67%)、人才不足(54%)、安全合规(48%)。",
"source": "麦肯锡中国",
"published_at": "2025-09-12",
"relevance_score": 0.87,
},
{
"title": "AI写作助手用户规模达2.3亿,内容质量争议持续",
"content": "国内AI写作工具月活用户突破2.3亿,覆盖学术论文、营销文案、新闻报道等场景。争议焦点:AI生成内容的版权归属、事实准确性(幻觉率约15%)、对写作从业者就业影响。",
"source": "艾瑞咨询",
"published_at": "2025-10-01",
"relevance_score": 0.79,
},
],
},
{
"keywords": ["碳中和", "双碳", "绿色", "低碳", "碳排放", "可再生能源", "光伏", "风电"],
"items": [
{
"title": "光伏装机量突破900GW,度电成本降至0.18元",
"content": "国家能源局数据显示,截至2025年9月,全国光伏累计装机量达912GW,占总装机的28%。光伏度电成本已降至0.18元,首次低于煤电基准价,推动更多省份出现「光伏平价」区域。",
"source": "国家能源局",
"published_at": "2025-10-12",
"relevance_score": 0.92,
},
{
"title": "碳市场价格突破200元/吨,倒逼高耗能行业转型",
"content": "全国碳排放权交易市场碳价已突破200元/吨,较2024年底上涨65%。水泥、电解铝、造纸等高耗能行业面临显著成本压力,碳减排投入年均增速达40%。",
"source": "上海环境能源交易所",
"published_at": "2025-09-28",
"relevance_score": 0.85,
},
],
},
{
"keywords": ["医疗", "健康", "药物", "生物", "医药", "病"],
"items": [
{
"title": "AI辅助诊断落地三甲医院,影像科漏诊率降低23%",
"content": "北京协和、上海瑞金等20家三甲医院数据显示,引入AI辅助诊断系统后,肺结节、眼底病变早期漏诊率降低23%,放射科医生平均阅片时间减少40%。监管层面,NMPA已批准38款AI医疗器械。",
"source": "医学界",
"published_at": "2025-09-18",
"relevance_score": 0.91,
},
],
},
{
"keywords": ["经济", "GDP", "贸易", "出口", "产业", "制造"],
"items": [
{
"title": "2025年前三季度GDP同比增长5.1%,消费贡献率提升至58%",
"content": "国家统计局公布,2025年前三季度GDP同比增长5.1%,最终消费对经济增长贡献率达58%,较去年同期提升8个百分点。工业增加值增长5.8%,其中装备制造业增长9.2%,成重要支撑。",
"source": "国家统计局",
"published_at": "2025-10-18",
"relevance_score": 0.90,
},
{
"title": "跨境电商出口额突破2.4万亿元,东南亚成最大增量市场",
"content": "商务部数据显示,2025年前三季度跨境电商出口额达2.4万亿元,同比增长28%。东南亚市场增速最快(+45%),中东、南美紧随其后。Temu、速卖通、Shopee三平台合计占出口份额约62%。",
"source": "商务部",
"published_at": "2025-10-05",
"relevance_score": 0.84,
},
],
},
]
# 通用兜底素材(与任意查询都能凑合搭配)
_FALLBACK_ITEMS = [
{
"title": "行业研究方法论:如何构建有效的一手与二手数据体系",
"content": "高质量行业报告需整合政府统计、上市公司财报、行业协会数据与专家访谈。一手调研样本量建议≥200份,二手数据优先选用权威机构发布的年度报告与季度白皮书,并注明数据时效性与口径差异。",
"source": "产业研究院",
"published_at": "2025-08-15",
"relevance_score": 0.55,
},
{
"title": "政策解读:2025年产业发展专项支持资金管理办法",
"content": "工信部发布2025年产业发展专项资金管理办法,重点支持「专精特新」企业数字化改造、绿色制造和关键技术攻关。单项目最高补贴1000万元,要求申报企业研发投入不低于营收3%。",
"source": "工信部",
"published_at": "2025-07-20",
"relevance_score": 0.50,
},
{
"title": "市场竞争格局分析:头部集中与长尾分散并存",
"content": "多个新兴行业呈现「哑铃型」竞争格局:头部3-5家企业合计市占率超60%,长尾中小企业依靠细分场景差异化生存,中间层企业面临最大竞争压力。并购整合预期升温,2025年战略性并购交易同比增长35%。",
"source": "普华永道",
"published_at": "2025-09-01",
"relevance_score": 0.48,
},
]
def _score_topic(query: str, keywords: list[str]) -> int:
"""简单关键词匹配打分"""
q = query.lower()
return sum(1 for kw in keywords if kw in q)
class DemoSearchProvider(BaseSearchProvider):
"""离线演示搜索提供者:根据查询关键词从内置语料库返回模拟素材。
仅用于开发测试;生产环境应配置 intranet 提供者。
"""
async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]:
best_topic = max(_DEMO_CORPUS, key=lambda t: _score_topic(query, t["keywords"]), default=None)
best_score = _score_topic(query, best_topic["keywords"]) if best_topic else 0
items = []
if best_score > 0 and best_topic:
items.extend(best_topic["items"])
# 用通用素材补齐
for fb in _FALLBACK_ITEMS:
if len(items) >= max_results:
break
items.append(fb)
results = []
for i, it in enumerate(items[:max_results]):
url_hash = hashlib.md5(it["title"].encode()).hexdigest()[:8]
results.append(SearchResult(
title=it["title"],
content=it["content"],
source=it["source"],
published_at=it["published_at"],
url=f"https://demo.internal/{url_hash}",
relevance_score=it["relevance_score"],
source_type="demo",
recUuid=f"demo-{i:03d}",
))
return results
async def health_check(self) -> bool:
return True
@@ -0,0 +1,76 @@
"""DuckDuckGo 搜索提供者 —— 免 API Key 互联网搜索。"""
from __future__ import annotations
import asyncio
import logging
from typing import List
from .base import BaseSearchProvider, SearchResult
logger = logging.getLogger(__name__)
class DuckDuckGoSearchProvider(BaseSearchProvider):
"""使用 DuckDuckGo 进行互联网搜索,无需 API Key。"""
def __init__(self, region: str = "cn-zh", max_timeout: int = 30):
self.region = region
self.max_timeout = max_timeout
async def search(
self,
query: str,
max_results: int = 5,
date_from: str = "",
date_to: str = "",
) -> List[SearchResult]:
try:
results = await asyncio.to_thread(
self._sync_search, query, max_results
)
return results
except Exception as e:
logger.warning("DuckDuckGo search failed for query=%r: %s", query, e)
return []
def _sync_search(self, query: str, max_results: int) -> List[SearchResult]:
try:
from ddgs import DDGS
except ImportError:
logger.error("ddgs not installed. Run: uv add ddgs")
return []
ddgs = DDGS(timeout=self.max_timeout)
try:
raw = ddgs.text(
query,
region=self.region,
safesearch="moderate",
max_results=max_results,
)
items = list(raw) if raw else []
except Exception as e:
logger.warning("DDGS.text failed: %s", e)
return []
results = []
for item in items:
url = item.get("href", item.get("link", ""))
source = url.split("/")[2] if url and "/" in url else "web"
results.append(SearchResult(
title=item.get("title", ""),
content=item.get("body", item.get("snippet", ""))[:500],
source=source,
published_at=item.get("published", ""),
url=url,
relevance_score=0.75,
source_type="internet",
))
return results
async def health_check(self) -> bool:
try:
results = await self.search("test", max_results=1)
return len(results) > 0
except Exception:
return False
@@ -0,0 +1,66 @@
import asyncio
import logging
from typing import List
from .base import BaseSearchProvider, SearchResult
logger = logging.getLogger(__name__)
class InternetSearchProvider(BaseSearchProvider):
"""互联网搜索,使用项目已有的 Tavily 搜索工具。"""
async def search(
self,
query: str,
max_results: int = 5,
date_from: str = "",
date_to: str = "",
) -> List[SearchResult]:
try:
results = await asyncio.to_thread(self._sync_search, query, max_results)
return results
except Exception as e:
logger.warning("internet search failed for query=%r: %s", query, e)
return []
def _sync_search(self, query: str, max_results: int) -> List[SearchResult]:
from deerflow.config import get_app_config
try:
from tavily import TavilyClient
except ImportError:
logger.warning("tavily not installed, falling back to demo search")
return []
config = get_app_config().get_tool_config("web_search")
api_key = None
if config is not None and "api_key" in config.model_extra:
api_key = config.model_extra.get("api_key")
if not api_key:
logger.warning("Tavily API key not configured, falling back to demo search")
return []
client = TavilyClient(api_key=api_key)
res = client.search(query, max_results=max_results)
results = []
for item in res.get("results", []):
content = item.get("content", "")
results.append(SearchResult(
title=item.get("title", ""),
content=content[:500],
source=item.get("url", "").split("/")[2] if "/" in item.get("url", "") else "web",
published_at=item.get("published_date", ""),
url=item.get("url", ""),
relevance_score=item.get("score", 0.0),
source_type="internet",
))
return results
async def health_check(self) -> bool:
try:
results = await self.search("test", max_results=1)
return True
except Exception:
return False
@@ -0,0 +1,57 @@
from typing import List
from .base import BaseSearchProvider, SearchResult
class IntranetSearchProvider(BaseSearchProvider):
"""内网搜索接口适配器。
内网系统需提供 HTTP 接口,推荐格式:
POST /api/search
请求体:{"query": "...", "page_size": 5}
响应体:{"total": 100, "results": [...]}
对接步骤:
1. 取消下方注释代码,调整字段映射
2. 在 config.yaml 的 intranet_field_map 中配置字段映射
3. 将 search_provider 改为 intranet
"""
def __init__(self, base_url: str, api_key: str = "", timeout: int = 10, field_map: dict = None):
self.base_url = base_url
self.api_key = api_key
self.timeout = timeout
self.field_map = field_map or {}
def _map(self, item: dict, field: str) -> str:
key = self.field_map.get(field, field)
return str(item.get(key, item.get(field, "")))
async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]:
# ── 对接内网时,取消以下注释 ──────────────────────────────────────
# import httpx
# headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
# async with httpx.AsyncClient(timeout=self.timeout) as client:
# resp = await client.post(
# f"{self.base_url}/api/search",
# json={"query": query, "page_size": max_results},
# headers=headers,
# )
# resp.raise_for_status()
# items = resp.json().get("results", [])
# return [
# SearchResult(
# title=self._map(it, "title"),
# content=self._map(it, "content")[:500],
# source=self._map(it, "source"),
# published_at=self._map(it, "published_at"),
# relevance_score=float(it.get(self.field_map.get("relevance_score", "relevance_score"), 0)),
# recUuid=self._map(it, "recUuid"),
# source_type="intranet",
# )
# for it in items
# ]
return []
async def health_check(self) -> bool:
return False
@@ -0,0 +1,108 @@
from typing import Annotated, List, Literal, Optional, TypedDict
import operator
# ── 素材 ──────────────────────────────────────────────────────────────────────
class Material(TypedDict):
id: str
title: str
content: str # 截断到 500 字
source: str
published_at: str
relevance_score: float
source_type: str # "internet" | "intranet"
url: str
recUuid: str
class MaterialPackage(TypedDict):
keywords: List[str]
materials: List[Material]
summary: str
# ── 大纲 ──────────────────────────────────────────────────────────────────────
class OutlineSection(TypedDict):
section_title: str
key_points: List[str]
material_ids: List[str]
class Outline(TypedDict):
title: str
sections: List[OutlineSection]
# ── 草稿 ──────────────────────────────────────────────────────────────────────
class DraftArticle(TypedDict):
title: str
outline: Outline
full_markdown: str
word_count: int
revision_count: int
# ── 编辑审核 ──────────────────────────────────────────────────────────────────
class ReviewIssue(TypedDict):
category: str # "fact" | "logic" | "language"
severity: str # "major" | "minor"
location: str
issue: str
suggestion: str
class ReviewResult(TypedDict):
verdict: str # "pass" | "reject"
fact_score: int
logic_score: int
language_score: int
overall_score: int
pass_threshold: int
revision_notes: List[ReviewIssue]
# ── 用户干预 ──────────────────────────────────────────────────────────────────
class UserInterventionPayload(TypedDict):
action: str
approved_material_ids: List[str]
extra_keywords: List[str]
edited_outline: Optional[Outline]
outline_feedback: str
user_revision_notes: str
override_verdict: bool
# ── 主状态 ────────────────────────────────────────────────────────────────────
WritingStatus = Literal[
"researching", "awaiting_material_confirm",
"writing_outline", "awaiting_outline_confirm",
"writing_draft", "awaiting_draft_confirm",
"reviewing", "awaiting_review_confirm",
"revising", "done", "error",
]
class AIWritingState(TypedDict):
# 写作配置
user_intent: str
article_type: str
target_audience: str
word_count_target: int
# Agent A 输出
material_package: Optional[MaterialPackage]
# Agent B 输出
current_outline: Optional[Outline]
drafts: Annotated[List[DraftArticle], operator.add]
current_draft: Optional[DraftArticle]
# Agent C 输出
review_result: Optional[ReviewResult]
# 用户干预数据(每个干预点会覆盖)
last_user_intervention: Optional[UserInterventionPayload]
# 流程控制
revision_count: int
max_revisions: int
status: WritingStatus
# SSE 进度推送队列(每个节点追加)
progress_events: Annotated[List[dict], operator.add]