Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
8440d02198
76 files changed
+10205
-39
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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}`);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .graph import AI_WRITING_GRAPH, build_ai_writing_graph
|
||||
|
||||
__all__ = ["AI_WRITING_GRAPH", "build_ai_writing_graph"]
|
||||
+231
@@ -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()
|
||||
Whitespace-only changes.
+232
@@ -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)}
|
||||
+153
@@ -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 '打回修改'}",
|
||||
},
|
||||
],
|
||||
}
|
||||
+92
@@ -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
|
||||
+152
@@ -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},
|
||||
],
|
||||
}
|
||||
+209
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
+113
@@ -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} 个章节"},
|
||||
],
|
||||
}
|
||||
Whitespace-only changes.
+52
@@ -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):"""
|
||||
+49
@@ -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条"}}"""
|
||||
+75
@@ -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} 字。
|
||||
要求:补充更多来自素材的事实、数据与分析,论述更充分、更具体;直接返回扩充后的完整章节正文,不要章节标题、不要解释。"""
|
||||
+63
@@ -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",
|
||||
]
|
||||
+40
@@ -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: ...
|
||||
+183
@@ -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
|
||||
+76
@@ -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
|
||||
+66
@@ -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
|
||||
+57
@@ -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
|
||||
+108
@@ -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]
|
||||
+11
@@ -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"]
|
||||
+28
@@ -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))
|
||||
+162
@@ -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
|
||||
+11
@@ -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"]
|
||||
+24
@@ -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))
|
||||
+123
@@ -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())
|
||||
+50
@@ -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")
|
||||
+107
@@ -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")
|
||||
+39
@@ -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")
|
||||
Reference in new issue
Block a user