This commit is contained in:
fengxiangdebaozi committed 2026-05-18 10:03:06 +08:00
commit 8440d02198
76 files changed
+10205 -39

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,69 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
export interface ArticleType {
id: string;
key: string;
label: string;
description: string | null;
prompt_hint: string | null;
default_word_count: number;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface CreateArticleTypeRequest {
key: string;
label: string;
description?: string;
prompt_hint?: string;
default_word_count?: number;
sort_order?: number;
}
export interface UpdateArticleTypeRequest {
key?: string;
label?: string;
description?: string | null;
prompt_hint?: string | null;
default_word_count?: number;
sort_order?: number;
}
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/article-types`;
export async function listArticleTypes(): Promise<ArticleType[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`);
return resp.json();
}
export async function createArticleType(data: CreateArticleTypeRequest): Promise<ArticleType> {
const resp = await apiFetch(BASE(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 409) throw new Error(`文章类型标识 "${data.key}" 已存在`);
if (!resp.ok) throw new Error(`创建文章类型失败: ${resp.status}`);
return resp.json();
}
export async function updateArticleType(id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 409) throw new Error(`文章类型标识已存在`);
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`更新文章类型失败: ${resp.status}`);
return resp.json();
}
export async function deleteArticleType(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`删除文章类型失败: ${resp.status}`);
}
@@ -0,0 +1,73 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { ProgressEvent, CompletedIntervention } from '../types/ai-writing';
export interface AIWritingSessionSummary {
id: string;
user_id: string | null;
title: string;
user_intent: string | null;
status: string;
draft_title: string | null;
draft_markdown: string | null;
completed_interventions: CompletedInterventionRecord[] | null;
review_result: Record<string, unknown> | null;
transcript: AIWritingTranscript | null;
created_at: string;
updated_at: string;
}
export interface CompletedInterventionRecord {
pausePoint: string;
action: string;
timestamp: string;
}
/** 完整对话时间线快照:与实时对话用同一份数据结构,历史回显时直接喂给 AIWritingTimeline。 */
export interface AIWritingTranscript {
progressEvents: ProgressEvent[];
completedInterventions: CompletedIntervention[];
}
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
return resp.json();
}
export async function getAIWritingSession(id: string): Promise<AIWritingSessionSummary> {
const resp = await apiFetch(`${BASE()}/${id}`);
if (!resp.ok) throw new Error(`获取会话失败: ${resp.status}`);
return resp.json();
}
export async function renameAIWritingSession(id: string, title: string): Promise<AIWritingSessionSummary> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!resp.ok) throw new Error(`重命名失败: ${resp.status}`);
return resp.json();
}
export async function deleteAIWritingSession(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
}
/** 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。 */
export async function saveAIWritingTranscript(
id: string,
transcript: AIWritingTranscript,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}/transcript`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transcript }),
});
if (!resp.ok) throw new Error(`保存对话时间线失败: ${resp.status}`);
}
@@ -0,0 +1,208 @@
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', 'research_progress',
'materials_ready', 'outline_chunk', 'outline_ready',
'draft_chunk', 'draft_ready',
'review_chunk', 'review_item_done', '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);
// review_item_done (streaming per-category result from sse_queue)
if ('category' in out) out.reviewItemCategory = out.category;
if ('score' in out) out.reviewItemScore = out.score;
if ('issues' in out && Array.isArray(out.issues)) {
out.reviewItemIssues = out.issues.map((x: unknown) =>
normalizeReviewIssue(x as Record<string, unknown>)
);
}
return out as Partial<ProgressEvent>;
}
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
signal?: AbortSignal,
): () => void {
let stopped = false;
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
.then(async (response) => {
if (!response.ok || !response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentType = '';
let currentData = '';
while (!stopped) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
currentData = line.slice(6).trim();
} else if (line === '' && currentType && currentData) {
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
try {
const parsed = JSON.parse(currentData);
const normalized = normalizeEvent(parsed);
onEvent({
type: currentType as ProgressEventType,
sessionId,
message: '',
...normalized,
} as ProgressEvent);
} catch {
// malformed JSON — skip
}
}
currentType = '';
currentData = '';
}
}
}
reader.releaseLock();
})
.catch(() => {});
return () => { stopped = true; };
}
/** 提交用户干预决策,恢复暂停的流程 */
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: payload.action,
approved_material_ids: payload.approvedMaterialIds ?? [],
extra_keywords: payload.extraKeywords ?? [],
edited_outline: payload.editedOutline ?? null,
outline_feedback: payload.outlineFeedback ?? '',
user_revision_notes: payload.userRevisionNotes ?? '',
override_verdict: payload.overrideVerdict ?? false,
selected_issue_indices: payload.selectedIssueIndices ?? null,
}),
});
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
}
+3 -5
View File
@@ -1,7 +1,5 @@
import { apiFetch } from "@/core/api/fetch-client";
import { env } from "@/env";
const BASE = (env.NEXT_PUBLIC_BACKEND_BASE_URL as string | undefined) ?? "http://localhost:8001";
import { getBackendBaseURL } from "@/core/config";
export type RewriteAction = "polish" | "expand" | "shorten" | "tone";
export type RewriteTone =
@@ -12,7 +10,7 @@ export type RewriteTone =
| "expressive";
export interface RewriteRequest {
threadId: string;
threadId?: string | null;
selection: { from: number; to: number; text: string };
action: RewriteAction;
tone?: RewriteTone;
@@ -71,7 +69,7 @@ export async function* streamRewrite(
req: RewriteRequest,
signal?: AbortSignal,
): AsyncGenerator<RewriteStreamEvent> {
const res = await apiFetch(`${BASE}/api/writing/rewrite`, {
const res = await apiFetch(`${getBackendBaseURL()}/api/writing/rewrite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
@@ -0,0 +1,59 @@
import { FileText, PenLine } from 'lucide-react';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
import type { CanvasArtifactText } from '../../types/artifact';
function DraftWaitingPlaceholder() {
return (
<div className="h-full flex flex-col items-center justify-center gap-5 select-none">
<div className="relative">
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center">
<FileText className="w-8 h-8 text-muted-foreground/40" />
</div>
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<PenLine className="w-3.5 h-3.5 text-primary/60" />
</div>
</div>
<div className="text-center space-y-1.5 max-w-[200px]">
<p className="text-sm font-medium text-foreground/60">草稿将在此处显示</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
完成写作流程后,AI 生成的内容将实时出现在这里
</p>
</div>
</div>
);
}
export function AIWritingDraftPanel() {
const { artifact } = useCanvasGraphContext();
if (!artifact) {
return (
<div className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
</div>
);
}
const content = getCurrentArtifactContent(artifact);
return (
<div className="h-full flex flex-col overflow-hidden border-l">
{/* 标题栏 */}
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{content?.title ?? '草稿预览'}
</span>
</div>
<div className="flex-1 overflow-hidden">
{content && !isCodeArtifact(content) ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
</div>
</div>
);
}
@@ -0,0 +1,201 @@
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';
import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
}
interface ModelOption {
name: string;
displayName: string;
}
const INPUT_CLASS =
'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';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800');
const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]);
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
// 初始化时加载文章类型
useEffect(() => {
refreshTypes();
}, [refreshTypes]);
// 类型加载完成后:若当前选中值不在列表中则切换为第一项
useEffect(() => {
if (articleTypes.length === 0) return;
const keys = articleTypes.map((t) => t.key);
if (!keys.includes(articleType)) {
const first = articleTypes[0];
setArticleType(first.key);
setWordCountInput(String(first.default_word_count));
}
}, [articleTypes]); // eslint-disable-line react-hooks/exhaustive-deps
// 切换文章类型时自动填入该类型的默认字数
function handleArticleTypeChange(key: string) {
setArticleType(key);
const found = articleTypes.find((t) => t.key === key);
if (found) {
setWordCountInput(String(found.default_word_count));
}
}
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 handleWordCountBlur() {
const num = parseInt(wordCountInput, 10);
if (isNaN(num) || num < 100) {
setWordCountInput('800');
} else {
setWordCountInput(String(num));
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
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={handleArticleTypeChange}
disabled={disabled || typesLoading}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent>
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} 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>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
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,202 @@
import { useState, useRef, useEffect } from 'react';
import { History, Trash2, Check, X, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
done: { label: '完成', color: 'text-green-500' },
error: { label: '错误', color: 'text-destructive' },
};
function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins} 分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} 小时前`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days} 天前`;
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onRename: (title: string) => void;
}
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
const { label, color } = STATUS_LABELS[session.status] ?? { label: session.status, color: 'text-muted-foreground' };
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
function commitRename() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== session.title) onRename(trimmed);
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') commitRename();
else if (e.key === 'Escape') { setEditValue(session.title); setEditing(false); }
}
return (
<div
className={cn(
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
)}
onClick={!editing ? onSelect : undefined}
>
<div className="flex-1 min-w-0">
{editing ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
className="flex-1 text-xs bg-background border rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-ring"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<button className="shrink-0 text-green-500 hover:text-green-600" onClick={commitRename}>
<Check className="w-3 h-3" />
</button>
<button className="shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setEditValue(session.title); setEditing(false); }}>
<X className="w-3 h-3" />
</button>
</div>
) : (
<p
className="text-xs font-medium truncate leading-snug"
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true); }}
title="双击重命名"
>
{session.title}
</p>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
</div>
</div>
{/* 删除按钮,hover 时显示 */}
{!editing && (
<button
className="opacity-0 group-hover:opacity-100 shrink-0 p-0.5 text-muted-foreground hover:text-destructive transition-opacity"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
title="删除"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
);
}
interface AIWritingHistoryDropdownProps {
sessions: AIWritingSessionSummary[];
selectedId: string | null;
loading: boolean;
onSelect: (session: AIWritingSessionSummary) => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
onRefresh: () => void;
}
export function AIWritingHistoryDropdown({
sessions,
selectedId,
loading,
onSelect,
onDelete,
onRename,
onRefresh,
}: AIWritingHistoryDropdownProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
function handleOpen() {
setOpen((v) => {
if (!v) onRefresh();
return !v;
});
}
return (
<div ref={containerRef} className="relative">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={handleOpen}
title="查看历史记录"
>
<History className="size-3" />
历史
</Button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border bg-popover shadow-lg overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-xs font-semibold">写作历史</span>
<button
className={cn('text-muted-foreground hover:text-foreground transition-transform', loading && 'animate-spin')}
onClick={onRefresh}
title="刷新"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
{/* 列表 */}
<div className="max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<div className="text-center py-6 text-xs text-muted-foreground">
{loading ? '加载中…' : '暂无历史记录'}
</div>
) : (
sessions.map((s) => (
<SessionItem
key={s.id}
session={s}
isSelected={s.id === selectedId}
onSelect={() => { onSelect(s); setOpen(false); }}
onDelete={() => onDelete(s.id)}
onRename={(title) => onRename(s.id, title)}
/>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,131 @@
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
import { AIWritingTimeline } from './AIWritingTimeline';
const PAUSE_LABELS: Record<string, string> = {
material_confirm: '素材确认',
outline_confirm: '大纲确认',
draft_confirm: '草稿确认',
review_confirm: '编辑意见处理',
};
const ACTION_LABELS: Record<string, string> = {
approve: '已批准',
re_search: '重新搜索',
re_outline: '重新规划',
finalize: '直接定稿',
to_editor: '提交审核',
user_revise: '手动修改',
accept_review: '接受意见',
force_finalize: '强制定稿',
};
const PAUSE_ICONS: Record<string, React.ElementType> = {
material_confirm: Search,
outline_confirm: PenLine,
draft_confirm: FileText,
review_confirm: ClipboardEdit,
};
function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) {
const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2;
const ts = record.timestamp
? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: '';
return (
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="w-3 h-3 text-primary" />
</div>
{!isLast && <div className="w-px flex-1 bg-border/60 mt-1" />}
</div>
<div className="pb-4 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
{PAUSE_LABELS[record.pausePoint] ?? record.pausePoint}
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-primary font-medium">
{ACTION_LABELS[record.action] ?? record.action}
</span>
</div>
{ts && <p className="text-[10px] text-muted-foreground mt-0.5">{ts}</p>}
</div>
</div>
);
}
interface AIWritingHistoryViewProps {
session: AIWritingSessionSummary;
}
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
const interventions = session.completed_interventions ?? [];
const transcript = session.transcript;
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0;
const createdAt = session.created_at
? new Date(session.created_at).toLocaleString('zh-CN', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
: '';
return (
<div className="flex-1 overflow-y-auto">
{/* 元信息 */}
<div className="px-4 pt-3">
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
{session.user_intent && (
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{session.user_intent}
</p>
)}
{createdAt && (
<p className="text-[10px] text-muted-foreground">{createdAt}</p>
)}
</div>
</div>
{hasTranscript ? (
/* 完整对话时间线回显 */
<AIWritingTimeline
events={transcript!.progressEvents}
completedInterventions={transcript!.completedInterventions ?? []}
currentPause={null}
/>
) : (
/* 旧会话无时间线快照时,回退到精简阶段列表 */
<div className="px-4 py-3 space-y-4">
{interventions.length > 0 ? (
<div>
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
<div>
{interventions.map((rec, i) => (
<InterventionTimelineItem
key={i}
record={rec}
isLast={i === interventions.length - 1}
/>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
)}
{session.status === 'done' && (
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<Star className="w-3.5 h-3.5" />
<span>写作完成</span>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { getCurrentArtifactContent } from '../../utils/artifact';
export function AIWritingPageHeader() {
const navigate = useNavigate();
const { artifact } = useCanvasGraphContext();
const currentContent = getCurrentArtifactContent(artifact);
return (
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate('/page/canvas')}
title="返回 Canvas 列表"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<PenLine className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold">AI 智能写作</span>
{currentContent?.title && (
<>
<Separator orientation="vertical" className="h-4" />
<span className="text-sm text-muted-foreground truncate">
{currentContent.title}
</span>
</>
)}
</div>
);
}
@@ -0,0 +1,388 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWriting';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
import { AIWritingHistoryView } from './AIWritingHistoryView';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
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 navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
// History view state — null means normal writing mode
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
const isIdle = state.status === 'idle';
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
const isAutoScrollingRef = useRef(false);
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
const autoScrollRef = useRef(true);
const [autoScroll, setAutoScroll] = useState(true);
const disableAutoScroll = useCallback(() => {
autoScrollRef.current = false;
setAutoScroll(false);
}, []);
// 新会话 / 重置后恢复自动滚底
useEffect(() => {
if (isIdle) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isIdle]);
// 开始写作 / 点击继续后恢复自动滚底
useEffect(() => {
if (isRunning && !state.isPaused) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isRunning, state.isPaused]);
// 监听用户手动滚动(挂载一次)
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
};
// scroll 事件:兼容拖动滚动条的场景
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
let lastTop = el.scrollTop;
const onScroll = () => {
const cur = el.scrollTop;
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
disableAutoScroll();
}
lastTop = cur;
};
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('scroll', onScroll);
};
}, [disableAutoScroll]);
// 内容变化时若自动滚底开启则滚到底部
useEffect(() => {
// 检查同步 ref,避免 state 异步导致的时序竞争
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
const el = scrollRef.current;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
}, [
autoScroll, // 点击按钮后重新触发一次
isRunning,
state.progressEvents.length,
state.streamingDraftSections.length,
state.streamingReviewItems.length,
state.outlineStreamingText,
state.currentPause,
]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
setHistorySession(session);
// Push historical draft to the right panel
if (session.draft_markdown) {
setArtifact({
currentIndex: 0,
contents: [{
index: 0,
type: 'text',
title: session.draft_title ?? session.title,
fullMarkdown: session.draft_markdown,
}],
});
} else {
setArtifact(null);
}
}, [setArtifact]);
const handleNewSession = useCallback(() => {
setHistorySession(null);
reset();
}, [reset]);
const showNewButton = historySession !== null || state.status !== 'idle';
// History view mode
if (historySession) {
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 truncate flex-1 min-w-0 mr-2">
{historySession.draft_title ?? historySession.title}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建对话
</Button>
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={historySession.id}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
<AIWritingHistoryView session={historySession} />
</div>
);
}
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 shrink-0">AI 智能写作</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
{showNewButton && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建
</Button>
)}
{isRunning && !state.isPaused && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={pauseWriting}
>
<Square className="size-3 fill-current" />
停止
</Button>
)}
{state.isPaused && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
onClick={resumeWriting}
>
<Play className="size-3 fill-current" />
继续
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
</>
)}
{(isDone || isError) && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
)}
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={null}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm onSubmit={startWriting} />
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
<AIWritingTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
currentInterventionSlot={
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
}
/>
)}
{/* 大纲流式思考框 */}
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
)}
{/* 编辑审核流式结果 */}
{state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
<ReviewStreamingView items={state.streamingReviewItems} />
)}
{/* 修改完成摘要 */}
{state.revisionSummary && (
<div className="px-4 pb-2">
<RevisionSummaryCard summary={state.revisionSummary} />
</div>
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
)}
{showSkeleton &&
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
</div>
)}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
</div>
)}
{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>{/* 滚动内容区结束 */}
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
{isRunning && !autoScroll && (
<div className="absolute inset-x-0 bottom-4 flex justify-center z-10 pointer-events-none">
<button
onClick={scrollToBottom}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-full bg-background border shadow-md hover:bg-muted transition-colors whitespace-nowrap"
>
<ChevronsDown className="size-3.5" />
回到底部
</button>
</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',
)}
>
{state.isPaused ? (
<>
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
已暂停
</>
) : (
<>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{state.status === 'researching' && state.researchProgress
? state.researchProgress
: STATUS_LABEL[state.status] ?? state.status}
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
import { CompletedInterventionCard } from './CompletedInterventionCard';
interface AIWritingTimelineProps {
events: ProgressEvent[];
completedInterventions: CompletedIntervention[];
currentPause: ProgressEvent | null;
currentInterventionSlot?: 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,
completedInterventions,
currentPause,
currentInterventionSlot,
}: AIWritingTimelineProps) {
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
if (visible.length === 0) return null;
// 遍历时追踪 await_user 事件的序号,以便从 completedInterventions 取对应记录
let awaitUserIdx = 0;
return (
<div className="px-4 py-3 space-y-0">
{visible.map((event, idx) => {
const isLast = idx === visible.length - 1;
let slot: React.ReactNode = null;
if (event.type === 'await_user') {
const completedRecord = completedInterventions[awaitUserIdx];
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 渲染折叠历史卡
slot = <CompletedInterventionCard record={completedRecord} />;
} else if (currentPause && isLast) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
}
}
return (
<TimelineStep key={idx} event={event} isLast={isLast && !slot}>
{slot}
</TimelineStep>
);
})}
</div>
);
}
@@ -0,0 +1,225 @@
import { useState } from 'react';
import {
CheckCircle2, ChevronRight, ChevronDown,
ExternalLink, ListTree, FileText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CompletedIntervention } from '../../types/ai-writing';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
import { ScoreBar } from './ScoreBar';
interface Props {
record: CompletedIntervention;
}
export function CompletedInterventionCard({ record }: Props) {
const [expanded, setExpanded] = useState(false);
if (record.pausePoint === 'material_confirm') {
return <MaterialCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'outline_confirm') {
return <OutlineCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'draft_confirm') {
return <DraftCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'review_confirm') {
return <ReviewCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
return null;
}
// ── 素材确认历史卡 ────────────────────────────────────────────────────────────
function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const count = record.materials?.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1 font-medium">
已确认{' '}
<span className="text-muted-foreground font-normal">{count} 条素材</span>
</span>
{count > 0 && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.materials && record.materials.length > 0 && (
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
{record.materials.map((m) => {
const pct = Math.round(m.relevanceScore * 100);
const barColor =
m.relevanceScore >= 0.8 ? 'bg-green-500'
: m.relevanceScore >= 0.5 ? 'bg-orange-400'
: 'bg-red-400';
return (
<div key={m.id} className="rounded border border-border/50 bg-background px-2.5 py-1.5 space-y-1">
<p className="font-medium leading-snug line-clamp-2">{m.title}</p>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="truncate max-w-[100px]">{m.source}</span>
{m.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{m.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', barColor)} style={{ width: `${pct}%` }} />
</div>
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
</div>
{m.url && (
<a href={m.url} target="_blank" rel="noopener noreferrer"
className="shrink-0 text-primary hover:underline inline-flex items-center gap-0.5">
原文 <ExternalLink className="size-2.5" />
</a>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ── 大纲确认历史卡 ────────────────────────────────────────────────────────────
function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const sectionCount = record.outline?.sections.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="flex-1 font-medium">
已确认大纲
{record.outline?.title && (
<span className="ml-1 text-muted-foreground font-normal">
《{record.outline.title}》
</span>
)}
<span className="ml-1 text-muted-foreground font-normal">共 {sectionCount} 章节</span>
</span>
{record.outline && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.outline && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
<OutlineCard outline={record.outline} readOnly />
</div>
)}
</div>
);
}
// ── 草稿确认历史卡 ────────────────────────────────────────────────────────────
function DraftCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const title = record.draftTitle ? `《${record.draftTitle}》` : '草稿';
const wordCount = record.draftWordCount ?? 0;
const hasReview = !!record.reviewResult;
const actionLabel =
record.action === 'finalize' ? '已确认定稿' :
record.action === 'user_revise' ? '已要求修改' :
'已提交编辑审核';
const actionColor =
record.action === 'finalize' ? 'text-green-600 dark:text-green-400' :
record.action === 'user_revise' ? 'text-orange-500 dark:text-orange-400' :
'text-blue-500 dark:text-blue-400';
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium shrink-0', actionColor)}>{actionLabel}</span>
<span className="text-muted-foreground flex-1 truncate">
{title}
{wordCount > 0 && <span className="ml-1">({wordCount} 字)</span>}
</span>
{hasReview && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.reviewResult && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
<ReviewCard review={record.reviewResult} />
</div>
)}
</div>
);
}
// ── 编辑审核确认历史卡 ────────────────────────────────────────────────────────
function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const isForce = record.action === 'force_finalize';
const hasReview = !!record.reviewResult;
const actionLabel = isForce ? '已强制定稿(忽略编辑意见)' : '已采纳编辑意见,重新写作';
const actionColor = isForce ? 'text-orange-500 dark:text-orange-400' : 'text-blue-500 dark:text-blue-400';
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium flex-1', actionColor)}>{actionLabel}</span>
{hasReview && record.reviewResult && (
<span className="text-muted-foreground tabular-nums shrink-0">
{record.reviewResult.overallScore} / 100
</span>
)}
{hasReview && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.reviewResult && (
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StreamingDraftSection } from '../../types/ai-writing';
interface DraftStreamingViewProps {
title: string;
sections: StreamingDraftSection[];
}
function TypewriterCursor() {
return (
<span
aria-hidden
className="inline-block w-0.5 h-4 bg-foreground align-middle ml-0.5 animate-[blink_1s_step-end_infinite]"
style={{ verticalAlign: 'text-bottom' }}
/>
);
}
function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDone: boolean }) {
const [open, setOpen] = useState(true);
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (open && preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [thinkingText, open]);
if (!thinkingText) return null;
return (
<div className="mb-2 rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
<span className="ml-auto text-amber-400">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
</button>
{open && (
<pre
ref={preRef}
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-32 overflow-y-auto italic"
>
{thinkingText}
{!isDone && (
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
);
}
export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [sections]);
const activeSectionIdx = sections.reduce(
(max, s) => (!s.isDone ? Math.max(max, s.sectionIndex) : max),
-1,
);
if (sections.length === 0) return null;
return (
<div className="px-4 pb-4">
<div className="rounded-xl border bg-card shadow-sm p-4 prose prose-sm dark:prose-invert max-w-none">
{title && (
<h1 className="text-base font-bold text-center mb-4 text-foreground">{title}</h1>
)}
{sections.map((sec) => (
<div key={sec.sectionIndex} className="mb-4">
<h2 className={cn(
'text-sm font-semibold mb-1.5',
sec.isDone ? 'text-foreground' : 'text-purple-500',
)}>
{sec.sectionTitle}
</h2>
{/* 该章节的思考过程(可折叠) */}
<SectionThinking thinkingText={sec.thinkingText} isDone={sec.isDone} />
{/* 正文内容 */}
<p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0">
{sec.text}
{sec.sectionIndex === activeSectionIdx && !sec.isDone && <TypewriterCursor />}
</p>
</div>
))}
<div ref={bottomRef} />
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,491 @@
import { useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
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>
);
}
// ── 工具函数 ──────────────────────────────────────────────────────────────────
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
return new Set(issues.map((_, i) => i));
}
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
const selected = issues.filter((_, i) => selectedIndices.has(i));
if (selected.length === 0) return '';
return selected
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
.join('\n');
}
// ── 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);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
setSubmitting(true);
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
setSubmitting(false);
}
async function handleApplyReview() {
if (!reviewResult) return;
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
setSubmitting(true);
await onSubmit({
action: 'user_revise',
userRevisionNotes: formattedNotes,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
const hasReview = reviewResult != null && reviewResult.revisionNotes.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">
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容
</p>
</div>
</div>
{/* 编辑审核结果(若已审核,支持勾选意见) */}
{reviewResult && (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
)}
{/* 用户自定义修改意见 */}
{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>
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
{hasReview && (
<Button
type="button" variant="outline" size="sm"
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
onClick={handleApplyReview}
disabled={disabled || submitting || selectedIssueIndices.size === 0}
>
<ClipboardEdit className="size-3.5" />
按编辑审核意见修改
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</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);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function handleAccept() {
setSubmitting(true);
const issueNotes = reviewResult
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
: '';
const combined = [issueNotes, extraNotes.trim()].filter(Boolean).join('\n\n');
await onSubmit({
action: 'accept_review',
userRevisionNotes: combined || undefined,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
async function handleForce() {
setSubmitting(true);
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
setSubmitting(false);
}
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 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">
勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。
</p>
</div>
</div>
{reviewResult ? (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
onToggleIssue={hasIssues ? toggleIssue : undefined}
/>
) : (
<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 || (hasIssues && selectedIssueIndices.size === 0 && !extraNotes.trim())}
>
<ClipboardEdit className="size-3.5" />按编辑意见修改
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</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,110 @@
import { cn } from '@/lib/utils';
import type { ReviewIssue } from '../../types/ai-writing';
interface IssueListProps {
issues: ReviewIssue[];
selectedIndices?: Set<number>;
onToggle?: (index: number) => void;
}
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, selectedIndices, onToggle }: IssueListProps) {
if (issues.length === 0) {
return <p className="text-xs text-muted-foreground text-center py-1">无需修改意见</p>;
}
const indexed = issues.map((issue, i) => ({ issue, index: i }));
const major = indexed.filter(({ issue }) => issue.severity === 'major');
const minor = indexed.filter(({ issue }) => issue.severity === 'minor');
const selectable = selectedIndices !== undefined && onToggle !== undefined;
return (
<div className="space-y-3">
{major.length > 0 && (
<IssueGroup
label="必须修改"
labelClass="text-red-500"
items={major}
selectable={selectable}
selectedIndices={selectedIndices}
onToggle={onToggle}
/>
)}
{minor.length > 0 && (
<IssueGroup
label="建议修改"
labelClass="text-yellow-500"
items={minor}
selectable={selectable}
selectedIndices={selectedIndices}
onToggle={onToggle}
/>
)}
</div>
);
}
interface IssueGroupProps {
label: string;
labelClass: string;
items: { issue: ReviewIssue; index: number }[];
selectable: boolean;
selectedIndices?: Set<number>;
onToggle?: (index: number) => void;
}
function IssueGroup({ label, labelClass, items, selectable, selectedIndices, onToggle }: IssueGroupProps) {
return (
<div className="space-y-1.5">
<p className={cn('text-xs font-semibold', labelClass)}>{label}</p>
{items.map(({ issue, index }) => {
const isSelected = !selectable || (selectedIndices?.has(index) ?? true);
return (
<div
key={index}
className={cn(
'rounded border bg-background px-2.5 py-2 space-y-1 text-xs transition-opacity',
selectable ? 'cursor-pointer' : '',
selectable && !isSelected ? 'opacity-40 border-border/30' : 'border-border/60',
)}
onClick={selectable ? () => onToggle?.(index) : undefined}
>
<div className="flex items-center gap-1.5">
{selectable && (
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggle?.(index)}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-primary cursor-pointer"
/>
)}
<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={cn('text-foreground leading-snug', selectable ? 'pl-5' : '')}>
{issue.issue}
</p>
<p className={cn('text-muted-foreground leading-snug', selectable ? 'pl-5' : '')}>
<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,143 @@
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 [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
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 reorderSections(from: number, to: number) {
if (from === to) return;
const sections = [...outline.sections];
const [moved] = sections.splice(from, 1);
sections.splice(to, 0, moved);
onChange?.({ ...outline, sections });
}
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}
isDragging={dragIndex === i}
isOver={overIndex === i && dragIndex !== null && dragIndex !== i}
onDragStart={() => setDragIndex(i)}
onDragEnter={() => setOverIndex(i)}
onDrop={() => {
if (dragIndex !== null && dragIndex !== i) reorderSections(dragIndex, i);
setDragIndex(null);
setOverIndex(null);
}}
onDragEnd={() => {
setDragIndex(null);
setOverIndex(null);
}}
/>
))}
</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,148 @@
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;
// Drag-and-drop
isDragging?: boolean;
isOver?: boolean;
onDragStart?: () => void;
onDragEnter?: () => void;
onDrop?: () => void;
onDragEnd?: () => void;
}
export function OutlineSection({
section, index, onChange, onRemove, readOnly,
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
}: 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
draggable={!readOnly}
onDragStart={(e) => { e.stopPropagation(); onDragStart?.(); }}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onDragEnter?.(); }}
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onDrop?.(); }}
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
className={cn(
'rounded border bg-background text-xs transition-all duration-150 select-none',
isDragging ? 'opacity-40 border-border/60' : isOver ? 'border-blue-400 ring-1 ring-blue-400/40' : 'border-border/60',
)}
>
{/* 章节头 */}
<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 active:cursor-grabbing" />
)}
<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,99 @@
import { useEffect, useRef, useState } from 'react';
import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface OutlineStreamingViewProps {
text: string;
thinkingText?: string;
}
export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) {
thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}
}, [thinkingText, thinkOpen]);
if (!text && !thinkingText) return null;
return (
<div className="px-4 pb-2 space-y-2">
{/* 思考过程区(仅在有思考内容时展示) */}
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
思考过程
</span>
<span className="ml-auto text-amber-400">
{thinkOpen
? <ChevronDown className="size-3.5" />
: <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{thinkingText}
{/* 思考仍在进行时显示光标 */}
{!text && (
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
{/* 正文流式区 */}
{text && (
<div className="rounded-xl border border-purple-200 dark:border-purple-900 bg-purple-50/50 dark:bg-purple-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200/60 dark:border-purple-900/60 bg-purple-100/40 dark:bg-purple-900/30">
<FileText className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
作家正在规划大纲…
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-purple-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
>
{text}
<span className="inline-block w-0.5 h-3.5 bg-purple-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,74 @@
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;
selectedIndices?: Set<number>;
onToggleIssue?: (index: number) => void;
}
export function ReviewCard({ review, revisionCount, selectedIndices, onToggleIssue }: 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">
<div className="flex items-center justify-between mb-2">
<p className="text-muted-foreground font-medium">修改意见</p>
{selectedIndices !== undefined && onToggleIssue && (
<p className="text-[10px] text-muted-foreground">
已选 {selectedIndices.size} / {review.revisionNotes.length}
</p>
)}
</div>
<IssueList
issues={review.revisionNotes}
selectedIndices={selectedIndices}
onToggle={onToggleIssue}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useRef } from 'react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StreamingReviewItem, ReviewIssue } from '../../types/ai-writing';
const CATEGORY_META: Record<
'fact' | 'logic' | 'language',
{ label: string; order: number }
> = {
fact: { label: '事实准确性', order: 0 },
logic: { label: '逻辑结构', order: 1 },
language: { label: '语言文字', order: 2 },
};
const CATEGORIES = (['fact', 'logic', 'language'] as const);
function ScoreBar({ score }: { score: number }) {
const color =
score >= 80 ? 'bg-green-400' :
score >= 60 ? 'bg-amber-400' :
'bg-red-400';
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${score}%` }}
/>
</div>
<span className="text-xs font-semibold tabular-nums w-8 text-right">{score}</span>
</div>
);
}
function IssueRow({ issue }: { issue: ReviewIssue }) {
return (
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5">
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
<span className="text-muted-foreground">{issue.issue}</span>
{issue.suggestion && (
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
)}
</div>
);
}
// 流式打字机文本块(审核进行中的分类显示)
function StreamingText({ item }: { item: StreamingReviewItem }) {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [item.streamingText, item.thinkingText]);
const hasContent = item.streamingText || item.thinkingText;
if (!hasContent) {
return (
<p className="text-[11px] text-muted-foreground pl-5 italic">正在审阅…</p>
);
}
return (
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed mt-1 p-2 rounded bg-muted/40 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto"
>
{item.thinkingText && (
<span className="italic text-amber-600/70 dark:text-amber-400/60">
{item.thinkingText}
</span>
)}
{item.streamingText}
<span className="inline-block w-0.5 h-3 bg-orange-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
);
}
interface ReviewStreamingViewProps {
items: StreamingReviewItem[];
}
export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
if (items.length === 0) return null;
const doneItems = items.filter((it) => it.isDone);
const allDone = doneItems.length === 3;
const overallScore =
doneItems.length > 0
? Math.round(doneItems.reduce((s, it) => s + it.score, 0) / doneItems.length)
: 0;
return (
<div className="px-4 pb-2">
<div className="rounded-xl border border-orange-200 dark:border-orange-900 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden shadow-sm">
{/* 标题栏 */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-200/60 dark:border-orange-900/60 bg-orange-100/40 dark:bg-orange-900/30">
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
编辑正在审核…
</span>
{!allDone && (
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-orange-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
)}
{allDone && (
<span className="ml-auto text-xs text-orange-600 dark:text-orange-400 font-semibold tabular-nums">
综合 {overallScore}
</span>
)}
</div>
{/* 逐项审核结果 */}
<div className="divide-y divide-orange-100 dark:divide-orange-900/50">
{CATEGORIES.map((cat) => {
const meta = CATEGORY_META[cat];
const item = items.find((it) => it.category === cat);
const isDone = item?.isDone ?? false;
const isStreaming = item != null && !item.isDone;
const isPending = item == null;
return (
<div key={cat} className="px-3 py-2 space-y-1.5">
{/* 分类行 */}
<div className="flex items-center gap-2">
{isDone ? (
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
) : isStreaming ? (
<Loader2 className="size-3.5 text-orange-400 animate-spin shrink-0" />
) : (
<span className="size-3.5 rounded-full border border-muted-foreground/30 shrink-0" />
)}
<span className={cn(
'text-xs font-medium',
isPending ? 'text-muted-foreground' : 'text-foreground',
)}>
{meta.label}
</span>
</div>
{/* 审核进行中:流式打字机文本 */}
{isStreaming && item && <StreamingText item={item} />}
{/* 审核完成:得分 + 问题列表 */}
{isDone && item && (
<>
<ScoreBar score={item.score} />
{item.issues.length > 0 ? (
<div className="space-y-1 mt-1">
{item.issues.slice(0, 3).map((iss, i) => (
<IssueRow key={i} issue={iss} />
))}
{item.issues.length > 3 && (
<p className="text-[11px] text-muted-foreground pl-2">
+{item.issues.length - 3} 条意见…
</p>
)}
</div>
) : (
<p className="text-[11px] text-green-600 dark:text-green-400 pl-5">
无问题
</p>
)}
</>
)}
</div>
);
})}
</div>
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,79 @@
import { CheckCircle2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { RevisionSummary } from '../../types/ai-writing';
interface RevisionSummaryCardProps {
summary: RevisionSummary;
}
const CATEGORY_LABEL: Record<string, string> = {
fact: '事实',
logic: '逻辑',
language: '语言',
};
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
const wordDiff = newWordCount - oldWordCount;
return (
<div className="rounded-md border border-green-200 dark:border-green-900/50 bg-green-50/50 dark:bg-green-950/20 overflow-hidden text-xs">
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-green-200/60 dark:border-green-900/60 bg-green-100/40 dark:bg-green-900/30">
<span className="font-medium text-green-700 dark:text-green-400">修改完成</span>
<span className="text-muted-foreground tabular-nums">
{newWordCount.toLocaleString()} 字
{wordDiff !== 0 && (
<span className={cn('ml-1', wordDiff > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
({wordDiff > 0 ? '+' : ''}{wordDiff})
</span>
)}
</span>
</div>
<div className="px-3 py-2.5 space-y-2">
{/* 已修改 */}
{revisedIssues.length > 0 && (
<div className="space-y-1">
<p className="text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
<CheckCircle2 className="size-3.5" />
已修改 {revisedIssues.length} 条意见
</p>
{revisedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground leading-snug">
<span className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
iss.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
iss.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
)}>
{CATEGORY_LABEL[iss.category] ?? iss.category}
</span>
<span>{iss.issue}</span>
</div>
))}
</div>
)}
{/* 已跳过 */}
{skippedIssues.length > 0 && (
<div className="space-y-1">
<p className="text-muted-foreground font-medium flex items-center gap-1">
<XCircle className="size-3.5" />
跳过 {skippedIssues.length} 条意见
</p>
{skippedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground/60 leading-snug line-through">
<span>{iss.issue}</span>
</div>
))}
</div>
)}
{revisedIssues.length === 0 && skippedIssues.length === 0 && (
<p className="text-muted-foreground text-center py-1">已完成修改</p>
)}
</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,70 @@
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, isLast: boolean): StepStatus {
if (event.type === 'await_user') return 'paused';
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
if (event.type === 'step_start') return isLast ? 'running' : 'done';
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, isLast ?? true);
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" />;
}
}
@@ -0,0 +1,232 @@
import { cn } from '@/lib/utils';
import type { WritingStatus } from '../../types/ai-writing';
interface WritingSkeletonCardProps {
status: WritingStatus;
/** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */
researchProgress?: string;
}
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
function SkeletonLine({ className }: { className?: string }) {
return (
<div className={cn('h-3 rounded-md bg-muted animate-pulse', className)} />
);
}
function ThinkingDots({ color = 'bg-blue-400' }: { color?: string }) {
return (
<span className="inline-flex gap-1 items-center">
{[0, 1, 2].map((i) => (
<span
key={i}
className={cn('size-1.5 rounded-full animate-bounce', color)}
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
);
}
// ── 素材收集骨架屏 ────────────────────────────────────────────────────────────
function MaterialsSkeleton({ researchProgress }: { researchProgress?: string }) {
return (
<div className="space-y-3 p-4">
{/* 标题行:展示素材收集专家的实时进度 */}
<div className="flex items-center gap-2 text-xs text-blue-500 font-medium">
<ThinkingDots color="bg-blue-400" />
<span>{researchProgress || '素材收集专家搜索中…'}</span>
</div>
{/* 关键词标签占位 */}
<div className="flex gap-1.5 flex-wrap">
{[60, 80, 50].map((w, i) => (
<div
key={i}
className="h-5 rounded-full bg-blue-100/60 dark:bg-blue-900/30 animate-pulse"
style={{ width: `${w}px` }}
/>
))}
</div>
{/* 素材卡片占位 */}
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border bg-muted/30 p-3 space-y-2">
{/* 标题 */}
<div className="flex items-start gap-2">
<SkeletonLine className={cn('flex-1 h-3.5', i % 3 === 0 ? 'w-4/5' : i % 3 === 1 ? 'w-3/4' : 'w-2/3')} />
</div>
{/* 来源 + 时间 + 相关度 */}
<div className="flex items-center gap-2">
<SkeletonLine className="w-20 h-2" />
<SkeletonLine className="w-16 h-2" />
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-blue-200/60 dark:bg-blue-700/40 animate-pulse"
style={{ width: `${55 + i * 8}%` }}
/>
</div>
</div>
{/* 摘要行 */}
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-5/6' : 'w-4/5')} />
</div>
))}
</div>
);
}
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
function OutlineSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家规划大纲中…</span>
</div>
{/* 文章标题占位 */}
<SkeletonLine className="w-3/5 h-4 mx-auto" />
{/* 章节列表 */}
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-2 pl-1">
<div className="mt-1 size-2 shrink-0 rounded-sm bg-purple-200/70 dark:bg-purple-800/40 animate-pulse" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className={cn('h-3', i % 2 === 0 ? 'w-2/5' : 'w-1/2')} />
<SkeletonLine className="w-3/5 h-2" />
{i % 2 === 0 && <SkeletonLine className="w-2/5 h-2" />}
</div>
</div>
))}
</div>
);
}
// ── 正文写作骨架屏 ────────────────────────────────────────────────────────────
function DraftSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家写作中…</span>
</div>
{/* 标题占位 */}
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{/* 章节内容 */}
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-11/12')} />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-3/4 h-2" />
</div>
))}
</div>
);
}
// ── 编辑审核骨架屏 ────────────────────────────────────────────────────────────
function ReviewSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-orange-500 font-medium">
<ThinkingDots color="bg-orange-400" />
<span>编辑审核中…</span>
</div>
{/* 综合评分占位 */}
<div className="flex items-center justify-between">
<SkeletonLine className="w-24 h-3" />
<SkeletonLine className="w-12 h-4" />
</div>
{/* 分项评分条 */}
{['事实准确度', '逻辑结构', '语言质量'].map((label, i) => (
<div key={label} className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">{label}</span>
<SkeletonLine className="w-8 h-3" />
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-orange-200/60 dark:bg-orange-700/40 animate-pulse"
style={{ width: `${55 + i * 10}%` }}
/>
</div>
</div>
))}
{/* 意见条目占位 */}
<div className="space-y-2 pt-1 border-t">
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-5/6 h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-4/5 h-2" />
</div>
</div>
);
}
// ── 按意见修改骨架屏 ──────────────────────────────────────────────────────────
function RevisingSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家按意见修改中…</span>
</div>
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-full')} />
</div>
))}
</div>
);
}
// ── 状态映射 ──────────────────────────────────────────────────────────────────
// researching / awaiting_material_confirm 由 WritingSkeletonCard 单独处理(需透传进度文案)
const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
// 实际运行状态
writing_outline: OutlineSkeleton,
writing_draft: DraftSkeleton,
reviewing: ReviewSkeleton,
revising: RevisingSkeleton,
// 过渡等待状态(用户提交后、后端事件到达前的空白期)
awaiting_outline_confirm: OutlineSkeleton, // 过渡到写作
awaiting_draft_confirm: DraftSkeleton, // 提交修改意见或编辑审核后
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
};
export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) {
// 素材收集阶段单独渲染,以便透传实时进度文案
if (status === 'researching' || status === 'awaiting_material_confirm') {
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<MaterialsSkeleton researchProgress={researchProgress} />
</div>
);
}
const Skeleton = SKELETON_MAP[status];
if (!Skeleton) return null;
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<Skeleton />
</div>
);
}
@@ -3,10 +3,9 @@ import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import type { Extension } from "@codemirror/state";
import type { ViewUpdate } from "@codemirror/view";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import CodeMirror, { type ViewUpdate } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useCallback, useMemo } from "react";
@@ -101,27 +101,35 @@ export function WritingRewritePreview({
// ── Position ────────────────────────────────────────────────────────────
const panelWidth = 480;
const containerRef = useRef<HTMLDivElement>(null);
const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null);
const [computedPos, setComputedPos] = useState<{
left: number;
top?: number;
bottom?: number;
maxHeight: number;
} | null>(null);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const h = el.getBoundingClientRect().height;
const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - panelWidth - 8);
const spaceBelow = window.innerHeight - pos.y - 8;
const spaceAbove = pos.y - 8;
let safeY: number;
if (spaceBelow >= h) {
safeY = pos.y + 6;
} else if (spaceAbove >= h) {
safeY = pos.y - h - 6;
} else if (spaceBelow >= spaceAbove) {
safeY = Math.max(8, window.innerHeight - h - 8);
const MARGIN = 8;
const GAP = 6;
const MIN_HEIGHT = 160;
const safeX = Math.min(Math.max(pos.x, MARGIN), window.innerWidth - panelWidth - MARGIN);
const spaceBelow = window.innerHeight - pos.y - MARGIN;
const spaceAbove = pos.y - MARGIN;
if (spaceBelow >= spaceAbove) {
// More space below — anchor top at selection bottom, grow downward
setComputedPos({
left: safeX,
top: pos.y + GAP,
maxHeight: Math.max(MIN_HEIGHT, spaceBelow - GAP),
});
} else {
safeY = 8;
// More space above — anchor bottom at selection top, grow upward
setComputedPos({
left: safeX,
bottom: window.innerHeight - pos.y + GAP,
maxHeight: Math.max(MIN_HEIGHT, spaceAbove - GAP),
});
}
setComputedPos({ left: safeX, top: safeY });
}, [pos.x, pos.y]);
const actionLabel = tone
@@ -134,16 +142,18 @@ export function WritingRewritePreview({
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
ref={containerRef}
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
top: computedPos?.top ?? (pos.y + 6),
...(computedPos?.bottom !== undefined
? { bottom: computedPos.bottom }
: { top: computedPos?.top ?? (pos.y + 6) }),
maxHeight: computedPos?.maxHeight,
width: panelWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="rounded-xl border bg-popover shadow-xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
className="flex flex-col rounded-xl border bg-popover shadow-xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/40">
@@ -179,7 +189,7 @@ export function WritingRewritePreview({
</div>
{/* Body */}
<div className="p-3 space-y-2 max-h-96 overflow-y-auto">
<div className="p-3 space-y-2 overflow-y-auto flex-1 min-h-0">
{/* Thinking — collapsible with animation */}
{thinking && (
@@ -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,413 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
AIWritingRequest,
AIWritingState,
AIMaterial,
AIOutline,
CompletedIntervention,
ProgressEvent,
ReviewResult,
StreamingDraftSection,
StreamingReviewItem,
UserInterventionPayload,
WritingStatus,
} from '../types/ai-writing';
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
isPaused: false,
progressEvents: [],
materials: [],
outline: null,
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
error: null,
streamingDraftTitle: '',
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
researchProgress: '',
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
};
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const [state, setState] = useState<AIWritingState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const unsubRef = useRef<(() => void) | null>(null);
const handleEvent = useCallback(
(event: ProgressEvent) => {
setState((prev) => {
const base: AIWritingState = {
...prev,
progressEvents: [...prev.progressEvents, event],
};
switch (event.type) {
case 'step_start':
// 兜底:编辑节点完成时若没有流式事件,也切到审核态
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
return { ...base, status: 'reviewing' };
}
return base;
case 'step_done':
return base;
case 'research_progress':
// 素材收集专家实时进度:仅更新最新提示文案
return { ...base, researchProgress: event.message ?? '' };
case 'resumed':
return { ...base, currentPause: null };
case 'materials_ready':
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹);进度文案已完成可清空
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
};
case 'outline_chunk': {
const chunkText = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
status: 'writing_outline',
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + chunkText,
outlineThinkingText: isThinking ? prev.outlineThinkingText + chunkText : prev.outlineThinkingText,
};
}
case 'outline_ready':
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本
return {
...base,
outline: (event.outline ?? null) as AIOutline | null,
outlineStreamingText: '',
outlineThinkingText: '',
};
case 'draft_chunk': {
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
const existing = sections.find((s) => s.sectionIndex === idx);
if (existing) {
if (isThinking) existing.thinkingText += text;
else existing.text += text;
existing.isDone = done;
} else {
sections.push({
sectionIndex: idx,
sectionTitle: title,
text: isThinking ? '' : text,
thinkingText: isThinking ? text : '',
isDone: done,
});
}
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
};
}
case 'review_chunk': {
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
// 首个 chunk 且非审核态 → 新一轮审核,清空上一轮残留
const isNewCycle = prev.status !== 'reviewing';
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
let item = items.find((it) => it.category === category);
if (!item) {
item = {
category, isDone: false,
streamingText: '', thinkingText: '',
score: 0, issues: [],
};
items.push(item);
}
if (isThinking) item.thinkingText += text;
else item.streamingText += text;
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'review_item_done': {
const category = event.reviewItemCategory ?? 'fact';
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
const item = items.find((it) => it.category === category);
if (item) {
item.isDone = true;
item.score = event.reviewItemScore ?? 80;
item.issues = event.reviewItemIssues ?? [];
} else {
// 兜底:没收到任何 chunk 直接来 done
items.push({
category, isDone: true,
streamingText: '', thinkingText: '',
score: event.reviewItemScore ?? 80,
issues: event.reviewItemIssues ?? [],
});
}
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'draft_ready': {
const newMarkdown = event.draftMarkdown ?? '';
const newWordCount = countWords(newMarkdown);
const summary = prev.pendingRevisionContext
? {
revisedIssues: prev.pendingRevisionContext.submittedIssues,
skippedIssues: prev.pendingRevisionContext.skippedIssues,
oldWordCount: prev.pendingRevisionContext.oldWordCount,
newWordCount,
}
: null;
return {
...base,
currentDraftMarkdown: newMarkdown,
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
streamingDraftSections: [],
streamingDraftTitle: '',
revisionSummary: summary,
pendingRevisionContext: null,
};
}
case 'review_ready':
return {
...base,
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
};
case 'await_user': {
const pp = event.pausePoint;
// Skip duplicate on SSE reconnect: backend re-injects the saved await_user event
// when queue is empty. If we already have this pause set, nothing needs to change.
if (prev.currentPause?.pausePoint === pp) return prev;
const pauseStatus = pp
? (`awaiting_${pp}` as AIWritingState['status'])
: prev.status;
// 若 await_user 里携带了素材/大纲/审核数据,一并更新(兜底 materials_ready 丢失的情况)
const extra: Partial<AIWritingState> = {};
if (pp === 'material_confirm' && event.materials?.length) {
extra.materials = event.materials as AIMaterial[];
}
if (pp === 'outline_confirm' && event.outline) {
extra.outline = event.outline as AIOutline;
}
return { ...base, ...extra, status: pauseStatus, currentPause: event };
}
case 'done':
return { ...base, status: 'done', currentPause: null };
case 'error':
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 || !state.currentPause) return;
// 若处于暂停状态,先重连 SSE 再提交,实现"干预即续接"
if (state.isPaused) {
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') {
record.materials = state.materials;
} else if (pausePoint === 'outline_confirm') {
record.outline = state.outline ?? undefined;
} else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
// 提交编辑意见修改时,记录本次选中/跳过的意见用于后续生成摘要
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview =
payload.selectedIssueIndices !== undefined &&
state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const submittedIssues = allIssues.filter((_, i) => selectedSet.has(i));
const skippedIssues = allIssues.filter((_, i) => !selectedSet.has(i));
pendingRevisionContext = {
submittedIssues,
skippedIssues,
oldWordCount: countWords(state.currentDraftMarkdown),
};
}
// Optimistic next-status so the correct skeleton shows immediately after
// the user clicks confirm, without waiting for the first SSE chunk.
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising'; // user_revise
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review
}
try {
await resumeWritingApi(state.sessionId, payload);
setState((prev) => ({
...prev,
currentPause: null,
status: nextStatus,
// Clear stale streaming state so new-cycle detection in chunk handlers works correctly
...(nextStatus === 'researching' ? { researchProgress: '' } : {}),
...(nextStatus === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(nextStatus === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(nextStatus === 'reviewing' ? { streamingReviewItems: [] } : {}),
...(nextStatus === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
completedInterventions: [...prev.completedInterventions, record],
...(pendingRevisionContext ? { pendingRevisionContext } : {}),
}));
} catch (err) {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
);
const pauseWriting = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setState((prev) => ({ ...prev, isPaused: true }));
}, []);
const resumeWriting = useCallback(() => {
if (!state.sessionId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}, [state.sessionId, handleEvent]);
const reset = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setArtifact(null);
setState(INITIAL_STATE);
}, [setArtifact]);
useEffect(() => () => { abortRef.current?.abort(); }, []);
// 在暂停点 / 完成 / 出错时,把完整对话时间线持久化到后端,供历史回显出与实时一致的内容
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
// 仅在状态进入可保存阶段时触发;events/interventions 取闭包最新值,无需进 deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset };
}
@@ -0,0 +1,45 @@
import { useCallback, useState } from 'react';
import {
listAIWritingSessions,
renameAIWritingSession,
deleteAIWritingSession,
} from '../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
export function useAIWritingSessions() {
const [sessions, setSessions] = useState<AIWritingSessionSummary[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listAIWritingSessions();
setSessions(data);
} catch {
// silently ignore — history is non-critical
} finally {
setLoading(false);
}
}, []);
const rename = useCallback(async (id: string, title: string) => {
try {
const updated = await renameAIWritingSession(id, title);
setSessions((prev) => prev.map((s) => (s.id === id ? updated : s)));
} catch {
// ignore
}
}, []);
const remove = useCallback(async (id: string) => {
try {
await deleteAIWritingSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
} catch {
// ignore
}
}, []);
return { sessions, loading, refresh, rename, remove };
}
@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import {
listArticleTypes,
createArticleType,
updateArticleType,
deleteArticleType,
} from '../api/ai-writing-article-types';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
export function useArticleTypes() {
const [articleTypes, setArticleTypes] = useState<ArticleType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listArticleTypes();
setArticleTypes(data);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
const create = useCallback(async (data: CreateArticleTypeRequest): Promise<ArticleType> => {
const created = await createArticleType(data);
setArticleTypes((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order));
return created;
}, []);
const update = useCallback(async (id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> => {
const updated = await updateArticleType(id, data);
setArticleTypes((prev) =>
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
);
return updated;
}, []);
const remove = useCallback(async (id: string): Promise<void> => {
await deleteArticleType(id);
setArticleTypes((prev) => prev.filter((t) => t.id !== id));
}, []);
return { articleTypes, loading, error, refresh, create, update, remove };
}
@@ -122,7 +122,7 @@ export function useWritingRewrite(editor: Editor | null) {
const rewrite = useCallback(
async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
if (!writingSelection || !threadId || !editor) return;
if (!writingSelection || !editor) return;
abortRef.current?.abort();
const abort = new AbortController();
@@ -152,12 +152,12 @@ export function useWritingRewrite(editor: Editor | null) {
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, threadId, modelName, editor, runStream],
[writingSelection, modelName, editor, runStream],
);
const rewriteFollowUp = useCallback(
async (instruction: string, action: RewriteAction, tone?: RewriteTone) => {
if (!writingSelection || !threadId || !editor) return;
if (!writingSelection || !editor) return;
const prevResult = result;
if (!prevResult) return;
@@ -190,7 +190,7 @@ export function useWritingRewrite(editor: Editor | null) {
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, threadId, modelName, editor, result, runStream],
[writingSelection, modelName, editor, result, runStream],
);
const reset = useCallback(() => {
@@ -0,0 +1,74 @@
import { useRef } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { GroupImperativeHandle } from 'react-resizable-panels';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPageHeader } from '../components/ai-writing/AIWritingPageHeader';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
// Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<AIWritingPageHeader />
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
<ResizablePanel
id={WRITING_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:草稿预览面板 */}
<ResizablePanel
id={DRAFT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -0,0 +1,431 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, FileText, Pencil, Plus, Settings2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useArticleTypes } from '../hooks/useArticleTypes';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
// ── 样式常量 ──────────────────────────────────────────────────────────────────
const INPUT_CLASS =
'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 disabled:bg-muted';
const TEXTAREA_CLASS =
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring resize-none';
// ── 表单状态类型 ───────────────────────────────────────────────────────────────
interface FormState {
key: string;
label: string;
description: string;
prompt_hint: string;
default_word_count: string;
sort_order: string;
}
const EMPTY_FORM: FormState = {
key: '',
label: '',
description: '',
prompt_hint: '',
default_word_count: '800',
sort_order: '0',
};
// ── 主页面 ────────────────────────────────────────────────────────────────────
export default function ArticleTypesConfigPage() {
const navigate = useNavigate();
const { articleTypes, loading, error, refresh, create, update, remove } = useArticleTypes();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
refresh();
}, [refresh]);
// ── 对话框操作 ──────────────────────────────────────────────────────────────
function openCreate() {
setEditingId(null);
setForm(EMPTY_FORM);
setFormError(null);
setDialogOpen(true);
}
function openEdit(t: ArticleType) {
setEditingId(t.id);
setForm({
key: t.key,
label: t.label,
description: t.description ?? '',
prompt_hint: t.prompt_hint ?? '',
default_word_count: String(t.default_word_count),
sort_order: String(t.sort_order),
});
setFormError(null);
setDialogOpen(true);
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
async function handleSave() {
const trimmedKey = form.key.trim();
const trimmedLabel = form.label.trim();
const wordCount = parseInt(form.default_word_count, 10);
if (!trimmedKey) { setFormError('标识不能为空'); return; }
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
setSaving(true);
setFormError(null);
try {
if (editingId) {
const req: UpdateArticleTypeRequest = {
label: trimmedLabel,
description: form.description.trim() || null,
prompt_hint: form.prompt_hint.trim() || null,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await update(editingId, req);
} else {
const req: CreateArticleTypeRequest = {
key: trimmedKey,
label: trimmedLabel,
description: form.description.trim() || undefined,
prompt_hint: form.prompt_hint.trim() || undefined,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await create(req);
}
setDialogOpen(false);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
// ── 删除操作 ────────────────────────────────────────────────────────────────
async function handleDelete() {
if (!deleteTargetId) return;
setDeleting(true);
setDeleteError(null);
try {
await remove(deleteTargetId);
setDeleteTargetId(null);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleting(false);
}
}
// ── 渲染 ────────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
{/* 页面头部 */}
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate(-1)}
title="返回"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Settings2 className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold flex-1">文章类型配置</span>
<Button size="sm" className="h-7 px-3 text-xs gap-1.5" onClick={openCreate}>
<Plus className="size-3.5" />
新增类型
</Button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-4">
{loading && (
<p className="text-sm text-muted-foreground text-center py-12">加载中…</p>
)}
{!loading && error && (
<p className="text-sm text-destructive text-center py-12">{error}</p>
)}
{!loading && !error && articleTypes.length === 0 && (
<div className="text-center py-12 space-y-2">
<Settings2 className="h-8 w-8 mx-auto text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无文章类型</p>
<p className="text-xs text-muted-foreground">点击右上角"新增类型"创建第一个文章类型</p>
</div>
)}
{!loading && articleTypes.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{articleTypes.map((t) => (
<ArticleTypeCard
key={t.id}
type={t}
onEdit={() => openEdit(t)}
onDelete={() => { setDeleteError(null); setDeleteTargetId(t.id); }}
/>
))}
</div>
)}
</div>
{/* 新增 / 编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!saving) setDialogOpen(open); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? '编辑文章类型' : '新增文章类型'}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<FormField label="标识 (key)" required>
<input
className={INPUT_CLASS}
placeholder="如 news、analysis(传给 AI 的唯一标识,仅限字母数字下划线)"
value={form.key}
onChange={(e) => setField('key', e.target.value)}
disabled={!!editingId}
/>
{editingId && (
<p className="text-[11px] text-muted-foreground mt-0.5">标识创建后不可修改</p>
)}
</FormField>
<FormField label="名称" required>
<input
className={INPUT_CLASS}
placeholder="如 新闻资讯、深度特稿"
value={form.label}
onChange={(e) => setField('label', e.target.value)}
/>
</FormField>
<FormField label="说明">
<input
className={INPUT_CLASS}
placeholder="简短描述,帮助用户了解该类型的适用场景"
value={form.description}
onChange={(e) => setField('description', e.target.value)}
/>
</FormField>
<FormField label="写作风格提示词">
<textarea
className={TEXTAREA_CLASS}
rows={3}
placeholder="传入 AI 的写作风格说明,如:以新闻报道风格写作,注重 5W1H 要素,语言简洁客观…"
value={form.prompt_hint}
onChange={(e) => setField('prompt_hint', e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="默认字数(字)" required>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
value={form.default_word_count}
onChange={(e) => setField('default_word_count', e.target.value)}
/>
</FormField>
<FormField label="排序权重">
<input
type="number"
min={0}
className={INPUT_CLASS}
placeholder="0"
value={form.sort_order}
onChange={(e) => setField('sort_order', e.target.value)}
/>
</FormField>
</div>
{formError && (
<p className="text-xs text-destructive">{formError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
取消
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog
open={deleteTargetId !== null}
onOpenChange={(open) => { if (!deleting && !open) setDeleteTargetId(null); }}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-2">
<p className="text-sm text-muted-foreground">
删除后不可恢复。已使用该类型创建的历史写作不受影响,但新写作将无法选择此类型。
</p>
{deleteError && (
<p className="text-xs text-destructive">{deleteError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTargetId(null)}
disabled={deleting}
>
取消
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? '删除中…' : '确认删除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── 文章类型卡片 ──────────────────────────────────────────────────────────────
function ArticleTypeCard({
type,
onEdit,
onDelete,
}: {
type: ArticleType;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2.5 hover:border-primary/30 transition-colors">
{/* 标题行 */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">{type.label}</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground border">
{type.key}
</span>
</div>
{type.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onEdit}
title="编辑"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive/70 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 写作提示词预览 */}
{type.prompt_hint && (
<div className="rounded-md bg-muted/50 px-2.5 py-2">
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{type.prompt_hint}
</p>
</div>
)}
{/* 底部元信息 */}
<div className="flex items-center gap-3 pt-0.5 border-t">
<span className="text-[11px] text-muted-foreground flex items-center gap-1 pt-2">
<FileText className="h-3 w-3" />
默认 {type.default_word_count} 字
</span>
<span className="text-[11px] text-muted-foreground pt-2">
排序 {type.sort_order}
</span>
</div>
</div>
);
}
// ── 表单字段包装 ──────────────────────────────────────────────────────────────
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
</div>
);
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
import { FileText, Code2, Sparkles, Loader2, PenLine } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createCanvasThread } from "../api/threads";
@@ -86,6 +86,23 @@ export default function CanvasHomePage() {
))}
</div>
{/* AI 智能写作入口 */}
<button
onClick={() => navigate('ai-writing')}
className="group w-full flex items-center gap-4 rounded-xl border border-orange-200 dark:border-orange-900/50 bg-orange-50/50 dark:bg-orange-950/20 p-5 text-left transition-all hover:border-orange-400/60 hover:shadow-md hover:bg-orange-50 dark:hover:bg-orange-950/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-400"
>
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/40 shrink-0 group-hover:bg-orange-200/80 dark:group-hover:bg-orange-800/40 transition-colors">
<PenLine className="w-5 h-5 text-orange-500 dark:text-orange-400" />
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm text-orange-700 dark:text-orange-300">AI 智能写作</p>
<p className="text-xs text-orange-600/70 dark:text-orange-400/60 leading-relaxed mt-0.5">
多智能体协作——搜集素材、规划大纲、起草正文、编辑审核,全流程自动完成
</p>
</div>
<span className="text-orange-400/60 shrink-0 text-xs">→</span>
</button>
<div className="text-center">
<Button
variant="ghost"
@@ -2,11 +2,15 @@ import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
);
@@ -0,0 +1,217 @@
// ── 素材 ─────────────────────────────────────────────────────────────────────
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'
| 'research_progress'
| 'materials_ready'
| 'outline_chunk'
| 'outline_ready'
| 'draft_chunk'
| 'draft_ready'
| 'review_chunk'
| 'review_item_done'
| '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_item_done (streaming per-category result)
reviewItemCategory?: 'fact' | 'logic' | 'language';
reviewItemScore?: number;
reviewItemIssues?: ReviewIssue[];
// 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;
// 用户勾选的审核意见索引(基于 reviewResult.revisionNotes),未传则默认全选
selectedIssueIndices?: number[];
}
// ── 写作请求 ──────────────────────────────────────────────────────────────────
export interface AIWritingRequest {
userIntent: string;
articleType: string;
targetAudience: string;
wordCountTarget: number;
modelName?: string;
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
export interface StreamingReviewItem {
category: 'fact' | 'logic' | 'language';
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
isDone: boolean;
// 流式过程文本(正文 / 思考分开)
streamingText: string;
thinkingText: string;
// 完成后的结构化结果
score: number;
issues: ReviewIssue[];
}
// ── 修改摘要 ──────────────────────────────────────────────────────────────────
export interface RevisionSummary {
revisedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
newWordCount: number;
}
// ── 流式草稿章节 ──────────────────────────────────────────────────────────────
export interface StreamingDraftSection {
sectionIndex: number;
sectionTitle: string;
text: string; // 正文内容(非思考)
thinkingText: string; // 思考内容(<think>...</think> 内)
isDone: boolean;
}
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
export interface CompletedIntervention {
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
action: string;
// material_confirm
materials?: AIMaterial[];
// outline_confirm
outline?: AIOutline;
// draft_confirm
draftTitle?: string;
draftWordCount?: number;
reviewResult?: ReviewResult;
// review_confirm
}
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
isPaused: boolean;
progressEvents: ProgressEvent[];
materials: AIMaterial[];
outline: AIOutline | null;
currentDraftMarkdown: string;
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
currentPause: ProgressEvent | null;
completedInterventions: CompletedIntervention[];
error: string | null;
// 流式草稿
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[];
// 修改摘要
revisionSummary: RevisionSummary | null;
// 待修改上下文(用于生成摘要)
pendingRevisionContext: {
submittedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
} | null;
}
@@ -0,0 +1,12 @@
/**
* 中文语境下的「字数」统计,与后端 `nodes/_utils.py:count_words` 保持同一口径:
* 汉字逐字计 + 中文标点逐个计 + 连续数字/英文各算 1 字。
* 不计入空白、markdown 语法符号(# * - 等)与 ASCII 标点。
*/
export function countWords(text: string): number {
if (!text) return 0;
const cjk = (text.match(/[一-鿿]/g) ?? []).length;
const cjkPunct = (text.match(/[,。、;:?!“”‘’()《》【】「」『』〈〉—…·~]/g) ?? []).length;
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
return cjk + cjkPunct + alnumRuns;
}
@@ -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)
@@ -122,6 +122,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.tag_store = make_tag_store(sf)
from deerflow.persistence.ai_writing_sessions import make_ai_writing_session_store
app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
from deerflow.persistence.article_types import make_article_type_store
app.state.article_type_store = make_article_type_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -222,6 +230,16 @@ def get_tag_store(request: Request) -> TagStore:
return val
def get_ai_writing_session_store(request: Request):
"""Return the AI writing session store, or None when not configured."""
return getattr(request.app.state, "ai_writing_session_store", None)
def get_article_type_store(request: Request):
"""Return the article type store, or None when not configured."""
return getattr(request.app.state, "article_type_store", None)
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@@ -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,627 @@
"""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 查询当前流程状态
GET /api/ai-writing/sessions 列出用户的会话历史
GET /api/ai-writing/sessions/{id} 获取单个历史会话
PATCH /api/ai-writing/sessions/{id} 重命名会话标题
DELETE /api/ai-writing/sessions/{id} 删除会话
GET /api/ai-writing/article-types 列出所有文章类型
POST /api/ai-writing/article-types 新增文章类型
PATCH /api/ai-writing/article-types/{id} 更新文章类型
DELETE /api/ai-writing/article-types/{id} 删除文章类型
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
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
pause_point: 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
selected_issue_indices: Optional[list[int]] = None
class RenameSessionRequest(BaseModel):
title: str
class SaveTranscriptRequest(BaseModel):
# 前端组装的完整对话时间线(progressEvents + completedInterventions)
transcript: dict
class ArticleTypeRequest(BaseModel):
key: str
label: str
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: int = 800
sort_order: int = 0
class UpdateArticleTypeRequest(BaseModel):
key: Optional[str] = None
label: Optional[str] = None
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: Optional[int] = None
sort_order: Optional[int] = None
class ArticleTypeResponse(BaseModel):
id: str
key: str
label: str
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: int
sort_order: int
created_at: str
updated_at: str
class SessionResponse(BaseModel):
id: str
user_id: Optional[str] = None
title: str
user_intent: Optional[str] = None
status: str
draft_title: Optional[str] = None
draft_markdown: Optional[str] = None
completed_interventions: Optional[list] = None
review_result: Optional[dict] = None
transcript: Optional[dict] = None
created_at: str
updated_at: str
# ── 内部状态 ──────────────────────────────────────────────────────────────────
# session_id → asyncio.Queue
_event_queues: dict[str, asyncio.Queue] = {}
# session_id → model_name(resume 时复用)
_session_model_names: dict[str, str] = {}
# session_id → AIWritingSessionRepository(for DB saves)
_session_repos: dict[str, object] = {}
# session_id → user_id (str | None, already resolved)
_session_user_ids: dict[str, str | None] = {}
# session_id → accumulated intervention records
_session_interventions: dict[str, list] = {}
# session_id → True if session ended with error
_session_error_flags: dict[str, bool] = {}
# session_id → valid_article_types(list[dict] with key/label)
_session_article_types: dict[str, list[dict]] = {}
# session_id → pending await_user event tuple for SSE reconnect re-delivery
# asyncio.Queue is consume-once; if the old SSE generator dequeued the await_user event
# before the client disconnected, the event is lost from the queue. We save a copy here
# so that a new SSE connection (e.g., after user clicks "继续") can re-inject it.
_session_pending_pause: dict[str, tuple] = {}
# ── 工具函数 ──────────────────────────────────────────────────────────────────
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 = "", valid_article_types: list[dict] | None = None) -> dict:
return {
"configurable": {
"thread_id": session_id,
"model_name": model_name or _session_model_names.get(session_id, ""),
"ai_writing_config": {},
# 把 SSE 队列注入,供草稿/大纲节点直接推流式 chunk
"sse_queue": _event_queues.get(session_id),
"sse_session_id": session_id,
# 动态文章类型列表,intent_parser 从此处读取有效枚举
"valid_article_types": valid_article_types or _session_article_types.get(session_id),
}
}
def _truncate_title(text: str, max_len: int = 50) -> str:
text = text.strip()
return text[:max_len] + ("…" if len(text) > max_len else "")
# ── 路由 ──────────────────────────────────────────────────────────────────────
@router.post("/start", response_model=StartWritingResponse)
async def start_writing(body: StartWritingRequest, request: Request):
"""启动写作流程,返回 session_id。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
from app.gateway.deps import get_ai_writing_session_store, get_article_type_store, get_current_user
session_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue()
_event_queues[session_id] = queue
_session_model_names[session_id] = body.model_name
_session_interventions[session_id] = []
# 加载动态文章类型列表,注入给 intent_parser 节点
article_type_store = get_article_type_store(request)
valid_article_types: list[dict] | None = None
if article_type_store is not None:
try:
records = await article_type_store.list()
if records:
valid_article_types = [{"key": r["key"], "label": r["label"]} for r in records]
except Exception:
logger.exception("ai_writing: failed to load article types for session=%s", session_id)
_session_article_types[session_id] = valid_article_types or []
# 持久化会话记录(DB 可能未配置,则跳过)
repo = get_ai_writing_session_store(request)
user_id = await get_current_user(request)
if repo is not None:
try:
title = _truncate_title(body.user_intent)
await repo.create(
id=session_id,
title=title,
user_intent=body.user_intent,
user_id=user_id,
)
_session_repos[session_id] = repo
_session_user_ids[session_id] = user_id
except Exception:
logger.exception("ai_writing: failed to create session record: session=%s", session_id)
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, valid_article_types=valid_article_types)
asyncio.create_task(_stream_graph_segment(session_id, initial_state, config, AI_WRITING_GRAPH))
return StartWritingResponse(session_id=session_id)
@router.get("/sessions", response_model=list[SessionResponse])
async def list_sessions(request: Request):
"""列出当前用户的 AI 写作历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
return []
user_id = await get_current_user(request)
return await repo.list(user_id=user_id)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str, request: Request):
"""获取单个历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
return record
@router.patch("/sessions/{session_id}", response_model=SessionResponse)
async def rename_session(session_id: str, body: RenameSessionRequest, request: Request):
"""重命名会话标题。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
updated = await repo.update(session_id, user_id=user_id, title=body.title.strip())
if updated is None:
raise HTTPException(status_code=404, detail="Session not found")
return updated
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str, request: Request):
"""删除历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
deleted = await repo.delete(session_id, user_id=user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Session not found")
return {"success": True}
@router.put("/sessions/{session_id}/transcript")
async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: Request):
"""保存会话的完整对话时间线,供历史会话回显出与实时对话一致的富时间线。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
return {"success": False}
user_id = await get_current_user(request)
updated = await repo.update(session_id, user_id=user_id, transcript=body.transcript)
if updated is None:
raise HTTPException(status_code=404, detail="Session not found")
return {"success": True}
@router.get("/article-types", response_model=list[ArticleTypeResponse])
async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
return []
return await store.list()
@router.post("/article-types", response_model=ArticleTypeResponse, status_code=201)
async def create_article_type(body: ArticleTypeRequest, request: Request):
"""新增文章类型。key 必须全局唯一。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
existing = await store.get_by_key(body.key)
if existing is not None:
raise HTTPException(status_code=409, detail=f"Article type key '{body.key}' already exists")
return await store.create(
key=body.key,
label=body.label,
description=body.description,
prompt_hint=body.prompt_hint,
default_word_count=body.default_word_count,
sort_order=body.sort_order,
)
@router.patch("/article-types/{article_type_id}", response_model=ArticleTypeResponse)
async def update_article_type(article_type_id: str, body: UpdateArticleTypeRequest, request: Request):
"""更新文章类型字段(只传需要修改的字段)。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
# 如果修改了 key,检查唯一性
if body.key is not None:
existing = await store.get_by_key(body.key)
if existing is not None and existing["id"] != article_type_id:
raise HTTPException(status_code=409, detail=f"Article type key '{body.key}' already exists")
_UNSET = object.__new__(object)
kwargs = {}
for field in ("key", "label", "description", "prompt_hint", "default_word_count", "sort_order"):
val = getattr(body, field)
if val is not None:
kwargs[field] = val
updated = await store.update(article_type_id, **kwargs)
if updated is None:
raise HTTPException(status_code=404, detail="Article type not found")
return updated
@router.delete("/article-types/{article_type_id}")
async def delete_article_type(article_type_id: str, request: Request):
"""删除文章类型。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
deleted = await store.delete(article_type_id)
if not deleted:
raise HTTPException(status_code=404, detail="Article type not found")
return {"success": True}
@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, request: Request):
"""用户提交干预决策,恢复暂停的流程。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
from app.gateway.deps import get_current_user
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,
"selectedIssueIndices": body.selected_issue_indices,
}
# Record this intervention for session history
record = {
"pausePoint": body.pause_point,
"action": body.action,
"timestamp": datetime.now(UTC).isoformat(),
}
if session_id in _session_interventions:
_session_interventions[session_id].append(record)
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
completed_interventions=_session_interventions[session_id],
)
except Exception:
logger.exception("ai_writing: failed to save intervention: session=%s", session_id)
# Clear pending pause so SSE reconnect won't re-inject the stale await_user event
_session_pending_pause.pop(session_id, None)
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
is_error = 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
event_tuple = ("await_user", interrupt_data)
await queue.put(event_tuple)
# Save for SSE reconnect re-delivery: if the old generator consumed this
# event before the client disconnected, a new connection would block forever.
_session_pending_pause[session_id] = event_tuple
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}))
# 草稿就绪 — 保存到 DB
if draft := node_output.get("current_draft"):
draft_title = draft.get("title", "")
draft_markdown = draft.get("full_markdown", "")
await queue.put(("draft_ready", {
"session_id": session_id,
"title": draft_title,
"markdown": draft_markdown,
"revision_count": draft.get("revision_count", 0),
}))
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="draft_ready",
title=draft_title or _truncate_title(
next(iter(_session_interventions.get(session_id, [{}])), {}).get("action", "草稿"),
),
draft_title=draft_title,
draft_markdown=draft_markdown,
)
except Exception:
logger.exception("ai_writing: failed to save draft: session=%s", session_id)
# 编辑审核就绪 — 保存到 DB
if review := node_output.get("review_result"):
await queue.put(("review_ready", {"session_id": session_id, "review_result": review}))
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="review_ready",
review_result=review,
)
except Exception:
logger.exception("ai_writing: failed to save review: session=%s", session_id)
# 状态 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)}))
is_error = True
reached_end = True
finally:
if reached_end:
# 更新最终状态到 DB
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="error" if is_error else "done",
)
except Exception:
logger.exception("ai_writing: failed to update final status: session=%s", session_id)
await queue.put(None) # 哨兵,通知 SSE 生成器关闭
_event_queues.pop(session_id, None)
_session_model_names.pop(session_id, None)
_session_repos.pop(session_id, None)
_session_user_ids.pop(session_id, None)
_session_interventions.pop(session_id, None)
_session_error_flags.pop(session_id, None)
_session_article_types.pop(session_id, None)
_session_pending_pause.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",
"draft_chunk", "outline_chunk"):
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})
# Re-inject the saved await_user event if the queue is empty and a pending pause exists.
# This covers the case where the old SSE generator dequeued the await_user event and
# yielded it to a disconnecting client — the event is then lost from the consume-once queue.
if queue.empty() and session_id in _session_pending_pause:
await queue.put(_session_pending_pause[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)
@@ -70,7 +70,7 @@ class SurroundingContext(BaseModel):
class RewriteRequest(BaseModel):
thread_id: str = Field(..., alias="threadId")
thread_id: str | None = Field(default=None, alias="threadId")
selection: SelectionInfo
action: str = Field(..., description="polish | expand | shorten | tone")
tone: str | None = Field(default=None)
@@ -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,232 @@
"""节点共用工具: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)
# 中文常见标点:参与「字数」统计,与 Word 字数统计 / 用户直觉口径对齐
_CJK_PUNCTUATION = ",。、;:?!“”‘’()《》【】「」『』〈〉—…·~"
def count_words(text: str) -> int:
"""统计中文语境下的「字数」。
口径:汉字逐字计 + 中文标点逐个计 + 连续数字/英文各算 1 字。
不计入空白、markdown 语法符号(# * - 等)与 ASCII 标点。
前后端共用同一口径(前端 utils/wordCount.ts 实现一致)。
"""
if not text:
return 0
cjk = len(re.findall(r"[一-鿿]", text))
cjk_punct = sum(1 for c in text if c in _CJK_PUNCTUATION)
alnum_runs = len(re.findall(r"[A-Za-z0-9]+", text))
return cjk + cjk_punct + alnum_runs
class ThinkTagParser:
"""流式 <think>...</think> 标签解析器。
模型可能在任意 chunk 边界截断标签,通过尾部缓冲处理跨 chunk 边界情况。
调用 feed(chunk) 返回 list[tuple[bool, str]],每项为 (is_thinking, text)。
"""
_OPEN_TAG = "<think>"
_CLOSE_TAG = "</think>"
def __init__(self) -> None:
self._in_think = False
self._buf = "" # 潜在标签的部分匹配缓冲
def feed(self, chunk: str) -> list[tuple[bool, str]]:
"""处理一个流式 chunk,返回 (is_thinking, text) 片段列表。"""
self._buf += chunk
result: list[tuple[bool, str]] = []
while self._buf:
if self._in_think:
tag = self._CLOSE_TAG
idx = self._buf.find(tag)
if idx != -1:
if idx > 0:
result.append((True, self._buf[:idx]))
self._buf = self._buf[idx + len(tag):]
self._in_think = False
else:
partial = self._partial_suffix(self._buf, tag)
if partial:
result.append((True, self._buf[:-partial]))
self._buf = self._buf[-partial:]
else:
result.append((True, self._buf))
self._buf = ""
break
else:
tag = self._OPEN_TAG
idx = self._buf.find(tag)
if idx != -1:
if idx > 0:
result.append((False, self._buf[:idx]))
self._buf = self._buf[idx + len(tag):]
self._in_think = True
else:
partial = self._partial_suffix(self._buf, tag)
if partial:
result.append((False, self._buf[:-partial]))
self._buf = self._buf[-partial:]
else:
result.append((False, self._buf))
self._buf = ""
break
return [(is_t, t) for (is_t, t) in result if t]
@staticmethod
def _partial_suffix(text: str, tag: str) -> int:
"""返回 text 尾部与 tag 前缀匹配的最长长度(>0 说明可能是部分标签)。"""
max_check = min(len(tag) - 1, len(text))
for length in range(max_check, 0, -1):
if tag.startswith(text[-length:]):
return length
return 0
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,153 @@
"""编辑节点:三项独立 LLM 审查(事实/逻辑/语言),逐 token 流式推送以支持打字机效果。"""
from __future__ import annotations
import asyncio
import logging
from langchain_core.messages import HumanMessage, SystemMessage
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 get_model, parse_json, _content_to_text, ThinkTagParser
logger = logging.getLogger(__name__)
_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 _stream_one_review(
model, user_prompt: str, category: str,
sse_queue: asyncio.Queue | None, session_id: str,
) -> str:
"""流式执行单项审查,逐 chunk 推送 review_chunk 事件,返回累积的正文文本(用于 JSON 解析)。"""
full_text = "" # 仅累积正文(非思考),供后续 JSON 解析
parser = ThinkTagParser()
async for chunk in model.astream([
SystemMessage(content=EDITOR_SYSTEM),
HumanMessage(content=user_prompt),
]):
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
if sse_queue is not None:
await sse_queue.put(("review_chunk", {
"session_id": session_id,
"category": category,
"text": text,
"is_thinking": is_thinking,
}))
return full_text
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)
cfg = config.get("configurable", {})
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
ai_writing_config = cfg.get("ai_writing_config", {}) or {}
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
REVIEW_TASKS = [
("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article)),
("logic", LOGIC_REVIEW_USER.format(article=article)),
("language", LANGUAGE_REVIEW_USER.format(article=article)),
]
model = get_model(config)
results: dict[str, dict] = {}
for category, user_prompt in REVIEW_TASKS:
fallback = {"score": 80, "issues": []}
try:
full_text = await _stream_one_review(model, user_prompt, category, sse_queue, session_id)
res = parse_json(full_text, fallback)
except Exception as e:
logger.warning("editor review [%s] failed: %s", category, e)
res = fallback
results[category] = res
# 这一项审查完成 —— 推送结构化结果(前端据此把流式文本替换为评分/意见)
if sse_queue is not None:
issues = _parse_issues(res.get("issues", []), category)
await sse_queue.put(("review_item_done", {
"session_id": session_id,
"category": category,
"score": int(res.get("score", 80)),
"issues": [dict(iss) for iss in issues],
}))
else:
logger.debug("editor [%s] done (no sse_queue)", category)
# 汇总
fact_score = int(results["fact"].get("score", 80))
logic_score = int(results["logic"].get("score", 80))
lang_score = int(results["language"].get("score", 80))
overall_score = (fact_score + logic_score + lang_score) // 3
all_issues: list[ReviewIssue] = (
_parse_issues(results["fact"].get("issues", []), "fact") +
_parse_issues(results["logic"].get("issues", []), "logic") +
_parse_issues(results["language"].get("issues", []), "language")
)
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 '打回修改'}",
},
],
}
@@ -0,0 +1,92 @@
"""意图解析节点:将用户自然语言意图结构化为写作配置。"""
from __future__ import annotations
from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState
from ._utils import llm_json
# 兜底类型列表,当 config 中未注入 valid_article_types 时使用
_DEFAULT_ARTICLE_TYPES: list[dict] = [
{"key": "news", "label": "新闻资讯"},
{"key": "analysis", "label": "分析报道"},
{"key": "feature", "label": "深度特稿"},
{"key": "report", "label": "调研报告"},
]
_SYSTEM_TEMPLATE = """你是一名写作助手。
你的任务:分析用户的写作意图,提取结构化的写作配置。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- article_type 只能是以下之一:{type_options}。
【完整示例】
输入意图:帮我写一篇面向行业专家的深度文章,分析今年新能源汽车出口为什么大涨,大概两千字
正确输出:
{{"article_type": "feature", "target_audience": "行业专家", "word_count_target": 2000, "refined_intent": "分析今年新能源汽车出口高速增长的原因"}}"""
_USER_TEMPLATE = """用户写作意图:{user_intent}
请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值):
{{{{
"article_type": "{type_options} 之一",
"target_audience": "目标读者描述",
"word_count_target": 800,
"refined_intent": "精炼后的写作意图(一句话)"
}}}}"""
def _build_type_options(types: list[dict]) -> str:
"""将类型列表格式化为提示词中的枚举说明,如 news(新闻资讯)/ analysis(分析报道)。"""
return " / ".join(f'{t["key"]}({t["label"]})' for t in types)
async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict:
# 从 configurable 读取动态类型列表,未配置时退回默认值
configurable = (config or {}).get("configurable", {})
valid_article_types: list[dict] = configurable.get("valid_article_types") or _DEFAULT_ARTICLE_TYPES
valid_keys: list[str] = [t["key"] for t in valid_article_types]
default_key: str = valid_keys[0] if valid_keys else "analysis"
type_options = _build_type_options(valid_article_types)
system = _SYSTEM_TEMPLATE.format(type_options=type_options)
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
type_options=" | ".join(valid_keys),
)
fallback_type = state.get("article_type", default_key)
if fallback_type not in valid_keys:
fallback_type = default_key
result = await llm_json(
system,
user,
fallback={
"article_type": fallback_type,
"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": "意图解析完成"}],
}
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
raw_type = result.get("article_type", "")
updates["article_type"] = raw_type if raw_type in valid_keys else fallback_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,152 @@
"""素材收集专家节点:关键词提取 → 并发搜索 → 相关度排序。"""
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 []
# SSE 实时进度推送(与 outline/draft 节点共用 sse_queue 机制)
cfg = config.get("configurable", {})
sse_queue = cfg.get("sse_queue")
sse_session_id: str = cfg.get("sse_session_id", "")
async def _emit(message: str) -> None:
if sse_queue is None:
return
await sse_queue.put(("research_progress", {
"session_id": sse_session_id,
"message": message,
}))
# Step 1: 提取关键词
await _emit("正在生成检索词…")
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_keywords = keywords[:6]
total = len(search_keywords)
await _emit(f"检索词已生成,开始检索 {total} 个方向…")
# 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁)
completed = 0
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=3)
finally:
completed += 1
if completed < total:
await _emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
await _emit(f"已完成全部 {total} 次检索")
search_tasks = [_search_one(kw) for kw in search_keywords]
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 排序)
await _emit("检索完成,正在筛选排序素材…")
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,209 @@
"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。"""
from __future__ import annotations
import asyncio
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, DRAFT_EXPAND_USER
from ._utils import get_model, _content_to_text, count_words, ThinkTagParser
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)
# SSE 流式推送所需
cfg = config.get("configurable", {})
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
# 修改意见(来自用户或编辑)
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", [])
n_sections = max(len(sections), 1)
# 单节硬下限:总目标 / 节数,乘 1.1 过量系数补偿模型对短目标的天然欠量;单节至少 150 字
words_per_section = max(150, int(word_count_target * 1.1 / n_sections))
article_title = outline.get("title", "文章")
# 兼容后端 snake_case 与前端编辑后回传的 camelCase
def _section_title(idx: int, sec: dict) -> str:
return sec.get("section_title") or sec.get("sectionTitle") or f"章节{idx + 1}"
model = get_model(config)
section_titles: list[str] = []
section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换
for i, section in enumerate(sections):
sec_title = _section_title(i, section)
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)
# 文章整体结构:让作家看到全文章节与自己的定位,避免章节间内容重复、引言结论脱节
outline_context = "\n".join(
f"{j + 1}. {_section_title(j, s)}" + (" ← 当前正在写这一章" if j == i else "")
for j, s in enumerate(sections)
)
user_msg = DRAFT_SECTION_USER.format(
article_title=article_title,
outline_context=outline_context,
section_title=sec_title,
key_points=key_points_str,
section_materials=section_materials,
revision_section=revision_notes,
target_words=words_per_section,
)
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
parser = ThinkTagParser()
try:
async for chunk in model.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
if sse_queue is not None:
await sse_queue.put(("draft_chunk", {
"session_id": session_id,
"section_index": i,
"section_title": sec_title,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
}))
# 章节完成
if sse_queue is not None:
await sse_queue.put(("draft_chunk", {
"session_id": session_id,
"section_index": i,
"section_title": sec_title,
"text": "",
"is_section_done": True,
}))
section_titles.append(sec_title)
section_bufs.append(section_buf.strip())
except Exception as e:
logger.error("draft section [%s] failed: %s", sec_title, e, exc_info=True)
section_titles.append(sec_title)
section_bufs.append("(内容生成失败,请重试)")
def _assemble() -> str:
body = "\n\n".join(f"## {t}\n\n{b}" for t, b in zip(section_titles, section_bufs))
return f"# {article_title}\n\n{body}"
full_markdown = _assemble()
word_count = count_words(full_markdown)
# 扩写兜底:总字数不足目标 90% 时,重写并扩充最短的章节(单轮,非流式)
if section_bufs and word_count < word_count_target * 0.9:
idx = min(range(len(section_bufs)), key=lambda k: count_words(section_bufs[k]))
target_sec = sections[idx]
expand_target = count_words(section_bufs[idx]) + (word_count_target - word_count)
try:
expand_msg = DRAFT_EXPAND_USER.format(
article_title=article_title,
section_title=section_titles[idx],
key_points="\n".join(
f"- {p}" for p in (target_sec.get("key_points") or target_sec.get("keyPoints") or [])
),
section_materials=_get_section_materials(
target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg
),
existing_text=section_bufs[idx],
target_words=expand_target,
)
resp = await model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
parser = ThinkTagParser()
expanded = "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
# 仅在扩写确实更长时才采用,避免越改越短
if count_words(expanded) > count_words(section_bufs[idx]):
section_bufs[idx] = expanded
full_markdown = _assemble()
word_count = count_words(full_markdown)
except Exception as e:
logger.warning("draft expand failed for section [%s]: %s", section_titles[idx], e)
draft = DraftArticle(
title=outline.get("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.get("title", "文章"),
"draft_markdown": full_markdown,
"revision_count": revision_count,
},
],
}
@@ -0,0 +1,113 @@
"""作家节点 Phase 1:生成文章大纲,支持流式 chunk 输出。"""
from __future__ import annotations
import asyncio
import logging
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, Outline, OutlineSection
from ..prompts.writer_prompts import OUTLINE_SYSTEM, OUTLINE_USER
from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser
logger = logging.getLogger(__name__)
# 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 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']}"
user_prompt = 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,
)
# SSE 流式推送
cfg = config.get("configurable", {})
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
model = get_model(config)
full_text = "" # 只累积正文(非思考)用于 JSON 解析
parser = ThinkTagParser()
try:
async for chunk in model.astream([
SystemMessage(content=OUTLINE_SYSTEM),
HumanMessage(content=user_prompt),
]):
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
if sse_queue is not None:
await sse_queue.put(("outline_chunk", {
"session_id": session_id,
"text": text,
"is_thinking": is_thinking,
}))
except Exception as e:
logger.error("outline streaming failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{"type": "error", "agent_name": "作家", "message": f"大纲生成失败:{e}"}],
}
fallback = {
"title": "未命名文章",
"sections": [
{"section_title": "引言", "key_points": ["背景介绍"], "material_ids": []},
{"section_title": "正文", "key_points": ["核心内容"], "material_ids": []},
{"section_title": "结论", "key_points": ["总结"], "material_ids": []},
],
}
result = parse_json(full_text, fallback)
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,75 @@
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,不要章节标题,不要解释。
- 不要写"以下是正文""本段内容如下"之类的开场白。
- 段落要连贯成文,不要罗列要点。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
- 充分展开每个要点:每个要点至少用 2-3 句话论述,结合素材中的事实与数据说理,写满要求的篇幅。"""
DRAFT_SECTION_USER = """文章标题:{article_title}
文章整体结构:
{outline_context}
当前章节:{section_title}
章节要点:{key_points}
相关素材:
{section_materials}
{revision_section}
请写出「{section_title}」这一章节的正文内容,不少于 {target_words} 字,论述充分、内容充实。"""
DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title}
章节要点:{key_points}
相关素材:
{section_materials}
这一章节已有内容如下:
{existing_text}
以上内容篇幅不足。请在保留原意和事实的基础上重写并扩充这一章节,使正文不少于 {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]
@@ -0,0 +1,11 @@
from deerflow.persistence.ai_writing_sessions.model import AIWritingSessionRow
from deerflow.persistence.ai_writing_sessions.sql import AIWritingSessionRepository
def make_ai_writing_session_store(session_factory) -> AIWritingSessionRepository | None:
if session_factory is None:
return None
return AIWritingSessionRepository(session_factory)
__all__ = ["AIWritingSessionRow", "AIWritingSessionRepository", "make_ai_writing_session_store"]
@@ -0,0 +1,28 @@
"""ORM model for AI writing sessions."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class AIWritingSessionRow(Base):
__tablename__ = "ai_writing_sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
user_intent: Mapped[str | None] = mapped_column(String(512), nullable=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="in_progress")
draft_title: Mapped[str | None] = mapped_column(String(512), nullable=True)
draft_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
completed_interventions: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
review_result: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object
# 完整对话时间线(前端组装的 progressEvents + completedInterventions),用于历史回显
transcript: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
@@ -0,0 +1,162 @@
"""SQLAlchemy-backed AI writing session storage."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.ai_writing_sessions.model import AIWritingSessionRow
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
_UNSET = object()
class AIWritingSessionRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: AIWritingSessionRow) -> dict:
d = row.to_dict()
for k in ("created_at", "updated_at"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
for k in ("completed_interventions", "review_result", "transcript"):
val = d.get(k)
if isinstance(val, str):
try:
d[k] = json.loads(val)
except Exception:
d[k] = None
return d
async def create(
self,
*,
id: str,
title: str,
user_intent: str | None = None,
user_id: str | None | _AutoSentinel = AUTO,
) -> dict:
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.create")
now = datetime.now(UTC)
row = AIWritingSessionRow(
id=id,
user_id=resolved_user_id,
title=title,
user_intent=user_intent,
status="in_progress",
draft_title=None,
draft_markdown=None,
completed_interventions=None,
review_result=None,
transcript=None,
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def update(
self,
session_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
title=_UNSET,
status=_UNSET,
draft_title=_UNSET,
draft_markdown=_UNSET,
completed_interventions=_UNSET,
review_result=_UNSET,
transcript=_UNSET,
) -> dict | None:
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.update")
async with self._sf() as session:
row = await session.get(AIWritingSessionRow, session_id)
if row is None:
return None
if resolved_user_id is not None and row.user_id != resolved_user_id:
return None
if title is not _UNSET:
row.title = title
if status is not _UNSET:
row.status = status
if draft_title is not _UNSET:
row.draft_title = draft_title
if draft_markdown is not _UNSET:
row.draft_markdown = draft_markdown
if completed_interventions is not _UNSET:
row.completed_interventions = (
json.dumps(completed_interventions, ensure_ascii=False)
if completed_interventions is not None
else None
)
if review_result is not _UNSET:
row.review_result = (
json.dumps(review_result, ensure_ascii=False)
if review_result is not None
else None
)
if transcript is not _UNSET:
row.transcript = (
json.dumps(transcript, ensure_ascii=False)
if transcript is not None
else None
)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(
self,
session_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> dict | None:
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.get")
async with self._sf() as session:
row = await session.get(AIWritingSessionRow, session_id)
if row is None:
return None
if resolved_user_id is not None and row.user_id != resolved_user_id:
return None
return self._row_to_dict(row)
async def list(
self,
*,
user_id: str | None | _AutoSentinel = AUTO,
limit: int = 50,
) -> list[dict]:
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.list")
stmt = select(AIWritingSessionRow)
if resolved_user_id is not None:
stmt = stmt.where(AIWritingSessionRow.user_id == resolved_user_id)
stmt = stmt.order_by(AIWritingSessionRow.updated_at.desc()).limit(limit)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def delete(
self,
session_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> bool:
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.delete")
async with self._sf() as session:
row = await session.get(AIWritingSessionRow, session_id)
if row is None:
return False
if resolved_user_id is not None and row.user_id != resolved_user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,11 @@
from deerflow.persistence.article_types.model import ArticleTypeRow
from deerflow.persistence.article_types.sql import ArticleTypeRepository
def make_article_type_store(session_factory) -> ArticleTypeRepository | None:
if session_factory is None:
return None
return ArticleTypeRepository(session_factory)
__all__ = ["ArticleTypeRow", "ArticleTypeRepository", "make_article_type_store"]
@@ -0,0 +1,24 @@
"""ORM model for AI writing article types."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ArticleTypeRow(Base):
__tablename__ = "ai_writing_article_types"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
label: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
prompt_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
default_word_count: Mapped[int] = mapped_column(Integer, nullable=False, default=800)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
@@ -0,0 +1,123 @@
"""SQLAlchemy-backed article type storage."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.article_types.model import ArticleTypeRow
_UNSET = object()
class ArticleTypeRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ArticleTypeRow) -> dict:
d = row.to_dict()
for k in ("created_at", "updated_at"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
return d
async def create(
self,
*,
key: str,
label: str,
description: str | None = None,
prompt_hint: str | None = None,
default_word_count: int = 800,
sort_order: int = 0,
) -> dict:
now = datetime.now(UTC)
row = ArticleTypeRow(
id=str(uuid.uuid4()),
key=key,
label=label,
description=description,
prompt_hint=prompt_hint,
default_word_count=default_word_count,
sort_order=sort_order,
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def update(
self,
article_type_id: str,
*,
key=_UNSET,
label=_UNSET,
description=_UNSET,
prompt_hint=_UNSET,
default_word_count=_UNSET,
sort_order=_UNSET,
) -> dict | None:
async with self._sf() as session:
row = await session.get(ArticleTypeRow, article_type_id)
if row is None:
return None
if key is not _UNSET:
row.key = key
if label is not _UNSET:
row.label = label
if description is not _UNSET:
row.description = description
if prompt_hint is not _UNSET:
row.prompt_hint = prompt_hint
if default_word_count is not _UNSET:
row.default_word_count = default_word_count
if sort_order is not _UNSET:
row.sort_order = sort_order
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(self, article_type_id: str) -> dict | None:
async with self._sf() as session:
row = await session.get(ArticleTypeRow, article_type_id)
if row is None:
return None
return self._row_to_dict(row)
async def get_by_key(self, key: str) -> dict | None:
async with self._sf() as session:
stmt = select(ArticleTypeRow).where(ArticleTypeRow.key == key)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row is None:
return None
return self._row_to_dict(row)
async def list(self) -> list[dict]:
stmt = select(ArticleTypeRow).order_by(ArticleTypeRow.sort_order.asc(), ArticleTypeRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def delete(self, article_type_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ArticleTypeRow, article_type_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def list_keys(self) -> list[str]:
stmt = select(ArticleTypeRow.key).order_by(ArticleTypeRow.sort_order.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return list(result.scalars())
@@ -0,0 +1,50 @@
"""Create ai_writing_sessions table.
Revision ID: 20260517_01
Revises: 20260515_01
Create Date: 2026-05-17
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260517_01"
down_revision = "20260515_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if _has_table("ai_writing_sessions"):
return
op.create_table(
"ai_writing_sessions",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=True),
sa.Column("title", sa.String(512), nullable=False, server_default=""),
sa.Column("user_intent", sa.String(512), nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="in_progress"),
sa.Column("draft_title", sa.String(512), nullable=True),
sa.Column("draft_markdown", sa.Text, nullable=True),
sa.Column("completed_interventions", sa.Text, nullable=True),
sa.Column("review_result", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_ai_writing_sessions_user_id", "ai_writing_sessions", ["user_id"])
op.create_index("ix_ai_writing_sessions_updated_at", "ai_writing_sessions", ["updated_at"])
def downgrade() -> None:
if _has_table("ai_writing_sessions"):
op.drop_table("ai_writing_sessions")
@@ -0,0 +1,107 @@
"""Create ai_writing_article_types table with default types.
Revision ID: 20260517_02
Revises: 20260517_01
Create Date: 2026-05-17
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260517_02"
down_revision = "20260517_01"
branch_labels = None
depends_on = None
_DEFAULT_TYPES = [
{
"key": "news",
"label": "新闻资讯",
"description": "时效性强的新闻报道,注重事实准确、简洁客观",
"prompt_hint": "以新闻报道的风格写作,注重5W1H要素,语言简洁客观,突出时效性和事实准确性",
"default_word_count": 600,
"sort_order": 0,
},
{
"key": "analysis",
"label": "分析报道",
"description": "深入分析事件背景与影响,提供专业见解",
"prompt_hint": "以分析报道的风格写作,提供深度背景分析、多角度解读和专业见解,逻辑清晰",
"default_word_count": 1200,
"sort_order": 1,
},
{
"key": "feature",
"label": "深度特稿",
"description": "人物或事件的深度叙事,注重细节与故事性",
"prompt_hint": "以深度特稿的风格写作,注重叙事性和细节描写,人物刻画生动,故事性强,情感真实",
"default_word_count": 2000,
"sort_order": 2,
},
{
"key": "report",
"label": "调研报告",
"description": "数据驱动的专题研究报告,结构严谨",
"prompt_hint": "以调研报告的风格写作,数据详实,结构严谨(摘要/背景/发现/结论),语言专业规范",
"default_word_count": 3000,
"sort_order": 3,
},
]
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("ai_writing_article_types"):
op.create_table(
"ai_writing_article_types",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("key", sa.String(64), nullable=False, unique=True),
sa.Column("label", sa.String(128), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("prompt_hint", sa.Text, nullable=True),
sa.Column("default_word_count", sa.Integer, nullable=False, server_default="800"),
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
# 预置默认文章类型(幂等,跳过已存在的 key)
conn = op.get_bind()
existing_keys = {row[0] for row in conn.execute(sa.text("SELECT key FROM ai_writing_article_types")).fetchall()}
now = datetime.now(UTC)
for t in _DEFAULT_TYPES:
if t["key"] not in existing_keys:
conn.execute(
sa.text(
"INSERT INTO ai_writing_article_types "
"(id, key, label, description, prompt_hint, default_word_count, sort_order, created_at, updated_at) "
"VALUES (:id, :key, :label, :description, :prompt_hint, :default_word_count, :sort_order, :created_at, :updated_at)"
),
{
"id": str(uuid.uuid4()),
"key": t["key"],
"label": t["label"],
"description": t["description"],
"prompt_hint": t["prompt_hint"],
"default_word_count": t["default_word_count"],
"sort_order": t["sort_order"],
"created_at": now,
"updated_at": now,
},
)
def downgrade() -> None:
if _has_table("ai_writing_article_types"):
op.drop_table("ai_writing_article_types")
@@ -0,0 +1,39 @@
"""Add transcript column to ai_writing_sessions.
存储前端组装的完整对话时间线(progressEvents + completedInterventions),
供历史会话回显出与实时对话一致的富时间线。
Revision ID: 20260517_03
Revises: 20260517_02
Create Date: 2026-05-17
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260517_03"
down_revision = "20260517_02"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
inspector = inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return False
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_column("ai_writing_sessions", "transcript"):
return
op.add_column("ai_writing_sessions", sa.Column("transcript", sa.Text, nullable=True))
def downgrade() -> None:
if _has_column("ai_writing_sessions", "transcript"):
op.drop_column("ai_writing_sessions", "transcript")