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

No files matched your search

File diff suppressed because it is too large. Load diff
+107
View File
@@ -0,0 +1,107 @@
# AI 智能写作 — 手工测试清单
> 测试环境:后端 `http://localhost:8001`,前端 `http://localhost:5174`
> 每项测试前确认后端已启动(`make dev`)、前端已启动(`pnpm dev`)
---
## T1 — 启动写作任务
| # | 步骤 | 预期结果 |
|---|------|----------|
| 1.1 | 打开 Canvas 页面,点击右上角「AI 写作」按钮 | AIWritingPanel 在右侧展开,显示写作配置表单 |
| 1.2 | 填写:意图「新能源汽车市场趋势分析」,类型「分析报告」,目标读者「行业研究人员」,字数「1200」 | 表单验证通过,提交按钮可点击 |
| 1.3 | 点击「开始写作」 | 按钮变为 loading 状态;时间轴出现第一条「素材收集专家 · 开始检索素材」进度条目 |
| 1.4 | 意图字段留空后点击提交 | 表单校验阻止提交,提示「请填写写作意图」 |
---
## T2 — SSE 进度流(时间轴)
| # | 步骤 | 预期结果 |
|---|------|----------|
| 2.1 | 任务启动后观察时间轴 | 每隔几秒新增进度条目,agent_name 标签分别出现「素材收集专家」「作家」「编辑」 |
| 2.2 | 打开浏览器 DevTools → Network,过滤 `stream` | 看到一个 `text/event-stream` 请求长连接,持续收到 `data:` 帧 |
| 2.3 | 断网 5 秒后恢复 | 面板显示「连接中断」提示(或 error 事件),不崩溃 |
---
## T3 — PAUSE-1 素材确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 3.1 | 等待素材收集专家完成检索,出现 PAUSE-1 干预卡 | 卡片标题「素材确认」,列出 ≥1 条素材,每条有复选框、标题、来源、相关度分数色条 |
| 3.2 | 全选所有素材,点击「确认素材,继续写作」 | 干预卡禁用,时间轴新增「作家 · 开始规划大纲」 |
| 3.3 | 重新发起任务,PAUSE-1 出现后取消勾选全部素材,点击「重新检索」 | 向后端发送 `action: re_search`;时间轴回到素材收集专家节点重新检索 |
| 3.4 | PAUSE-1 出现后填写「追加关键词」输入框(如「锂电池」)并按 Enter,再点「追加搜索」 | 标签出现「锂电池」;后端收到 `action: append_search, extraKeywords: ["锂电池"]` |
| 3.5 | 验证相关度色条颜色:分数 ≥0.8 绿色 / 0.5–0.8 橙色 / <0.5 红色 | 三种颜色均可在不同素材中观察到 |
---
## T4 — PAUSE-2 大纲确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 4.1 | PAUSE-2 出现后检查大纲卡 | 显示文章标题 + ≥2 个章节,每节有节标题与要点列表 |
| 4.2 | 点击文章标题,内联编辑改为「2025 新能源趋势报告」,按 Enter | 标题实时变更,不刷新页面 |
| 4.3 | 展开某章节,点击一条要点,修改文字,按 Blur 失焦 | 要点文字更新 |
| 4.4 | 点击「+ 新增章节」,填写章节标题 | 大纲末尾追加新章节 |
| 4.5 | 点击「确认大纲,开始写作」 | 编辑后的大纲随 `action: confirm_outline` 提交;时间轴出现「作家 · 开始撰写草稿」 |
| 4.6 | PAUSE-2 出现后点击「重新规划大纲」 | 显示意见输入框;填写「请增加政策分析章节」后提交;后端收到 `action: re_outline, outlineFeedback: ...` |
---
## T5 — PAUSE-3 草稿确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 5.1 | 作家完成草稿,PAUSE-3 出现 | 干预卡显示修改次数(如「第 1 稿」);右侧 Artifact 面板同步显示 Markdown 草稿内容 |
| 5.2 | 点击「定稿,提交编辑审核」 | 进入编辑节点;时间轴出现「编辑 · 开始审核」 |
| 5.3 | 点击「提出修改意见」,填写意见后提交 | 作家节点重新生成草稿;修改次数 +1 |
| 5.4 | 连续打回 3 次,观察第 4 次 PAUSE-3 | revisionCount 正确递增显示 |
---
## T6 — PAUSE-4 审核结果确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 6.1 | 编辑审核完成,PAUSE-4 出现 | ReviewCard 显示:综合得分、事实/逻辑/语言三项分数、及格线(默认 75)、verdict 徽章(通过=绿 / 打回=红)|
| 6.2 | verdict=pass 时,点击「接受审核,定稿」 | 流程结束;时间轴出现「完成」;右侧面板保留最终草稿 |
| 6.3 | verdict=reject 时,查看问题列表 | major 问题排在 minor 前;每条显示类别徽章、位置、问题描述、建议 |
| 6.4 | verdict=reject 时,点击「强制定稿」 | 向后端发送 `action: force_finalize`;流程结束 |
| 6.5 | 添加「额外说明」文字后点击「接受审核,定稿」 | extra_notes 包含在提交的 intervention payload 中(DevTools → Network 验证) |
---
## T7 — 错误与边界场景
| # | 步骤 | 预期结果 |
|---|------|----------|
| 7.1 | 后端未启动时点击「开始写作」 | 面板显示错误提示「无法连接到服务器」,不白屏 |
| 7.2 | 写作进行中点击「停止/重置」 | 时间轴清空,表单恢复可编辑,后端流被中断(Network 连接关闭)|
| 7.3 | 素材列表为空(后端返回 0 条)时 PAUSE-1 | 干预卡显示「暂无素材」提示,「确认」按钮仍可点击 |
| 7.4 | 大纲只有 1 个章节时,点击删除该章节 | 删除按钮禁用或弹出「至少保留一个章节」提示 |
| 7.5 | 弱模型返回格式错误的 JSON(手动 mock 后端) | 前端收到 fallback 结构,不崩溃;时间轴正常继续 |
---
## T8 — 跨阶段状态一致性
| # | 步骤 | 预期结果 |
|---|------|----------|
| 8.1 | PAUSE-2 时展开大纲卡,刷新页面(F5) | 页面恢复;如写作任务 ID 持久化,状态可恢复(否则回到初始状态,均可接受)|
| 8.2 | 同时打开两个浏览器标签,各自启动独立写作任务 | 两个任务互不干扰,各自有独立 task_id |
| 8.3 | 完整跑通一次全流程(T1→T2→T3→T4→T5→T6 pass) | 右侧 Artifact 面板显示完整定稿文章,时间轴所有节点状态为「完成」 |
---
## 后端单元测试(补充确认)
```bash
# 在 offline-backend-20260512/backend/ 下运行
PYTHONPATH=. uv run pytest tests/test_ai_writing_json_parse.py -v # 16 tests
PYTHONPATH=. uv run pytest tests/test_ai_writing_graph.py -v # 19 tests
```
两个测试文件均应全部通过(0 failed)。
@@ -0,0 +1,69 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
export interface ArticleType {
id: string;
key: string;
label: string;
description: string | null;
prompt_hint: string | null;
default_word_count: number;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface CreateArticleTypeRequest {
key: string;
label: string;
description?: string;
prompt_hint?: string;
default_word_count?: number;
sort_order?: number;
}
export interface UpdateArticleTypeRequest {
key?: string;
label?: string;
description?: string | null;
prompt_hint?: string | null;
default_word_count?: number;
sort_order?: number;
}
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/article-types`;
export async function listArticleTypes(): Promise<ArticleType[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`);
return resp.json();
}
export async function createArticleType(data: CreateArticleTypeRequest): Promise<ArticleType> {
const resp = await apiFetch(BASE(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 409) throw new Error(`文章类型标识 "${data.key}" 已存在`);
if (!resp.ok) throw new Error(`创建文章类型失败: ${resp.status}`);
return resp.json();
}
export async function updateArticleType(id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 409) throw new Error(`文章类型标识已存在`);
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`更新文章类型失败: ${resp.status}`);
return resp.json();
}
export async function deleteArticleType(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`删除文章类型失败: ${resp.status}`);
}
@@ -0,0 +1,73 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { ProgressEvent, CompletedIntervention } from '../types/ai-writing';
export interface AIWritingSessionSummary {
id: string;
user_id: string | null;
title: string;
user_intent: string | null;
status: string;
draft_title: string | null;
draft_markdown: string | null;
completed_interventions: CompletedInterventionRecord[] | null;
review_result: Record<string, unknown> | null;
transcript: AIWritingTranscript | null;
created_at: string;
updated_at: string;
}
export interface CompletedInterventionRecord {
pausePoint: string;
action: string;
timestamp: string;
}
/** 完整对话时间线快照:与实时对话用同一份数据结构,历史回显时直接喂给 AIWritingTimeline。 */
export interface AIWritingTranscript {
progressEvents: ProgressEvent[];
completedInterventions: CompletedIntervention[];
}
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
return resp.json();
}
export async function getAIWritingSession(id: string): Promise<AIWritingSessionSummary> {
const resp = await apiFetch(`${BASE()}/${id}`);
if (!resp.ok) throw new Error(`获取会话失败: ${resp.status}`);
return resp.json();
}
export async function renameAIWritingSession(id: string, title: string): Promise<AIWritingSessionSummary> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!resp.ok) throw new Error(`重命名失败: ${resp.status}`);
return resp.json();
}
export async function deleteAIWritingSession(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
}
/** 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。 */
export async function saveAIWritingTranscript(
id: string,
transcript: AIWritingTranscript,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}/transcript`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transcript }),
});
if (!resp.ok) throw new Error(`保存对话时间线失败: ${resp.status}`);
}
@@ -0,0 +1,208 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type {
AIWritingRequest,
ProgressEvent,
ProgressEventType,
UserInterventionPayload,
} from '../types/ai-writing';
const BASE = () => `${getBackendBaseURL()}/api/ai-writing`;
/** 启动写作流程,返回 session_id */
export async function startWritingSession(req: AIWritingRequest): Promise<string> {
const resp = await apiFetch(`${BASE()}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_intent: req.userIntent,
article_type: req.articleType,
target_audience: req.targetAudience,
word_count_target: req.wordCountTarget,
model_name: req.modelName ?? '',
}),
});
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
const data = await resp.json();
return data.session_id;
}
const SSE_EVENT_TYPES: ProgressEventType[] = [
'connected', 'step_start', 'step_done', 'research_progress',
'materials_ready', 'outline_chunk', 'outline_ready',
'draft_chunk', 'draft_ready',
'review_chunk', 'review_item_done', 'review_ready',
'await_user', 'resumed', 'done', 'error',
];
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
function normalizeMaterial(m: Record<string, unknown>): Record<string, unknown> {
return {
...m,
relevanceScore: m.relevance_score ?? m.relevanceScore ?? 0,
publishedAt: m.published_at ?? m.publishedAt ?? '',
sourceType: m.source_type ?? m.sourceType ?? 'internet',
recUuid: m.recUuid ?? m.rec_uuid ?? '',
};
}
function normalizeOutlineSection(s: Record<string, unknown>): Record<string, unknown> {
return {
...s,
sectionTitle: s.section_title ?? s.sectionTitle ?? '',
keyPoints: s.key_points ?? s.keyPoints ?? [],
materialIds: s.material_ids ?? s.materialIds ?? [],
};
}
function normalizeOutline(o: unknown): unknown {
if (!o || typeof o !== 'object') return o;
const obj = o as Record<string, unknown>;
const sections = Array.isArray(obj.sections)
? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record<string, unknown>))
: [];
return { ...obj, sections };
}
function normalizeReviewIssue(i: Record<string, unknown>): Record<string, unknown> {
return { ...i };
}
function normalizeReviewResult(r: unknown): unknown {
if (!r || typeof r !== 'object') return r;
const obj = r as Record<string, unknown>;
return {
...obj,
factScore: obj.fact_score ?? obj.factScore ?? 0,
logicScore: obj.logic_score ?? obj.logicScore ?? 0,
languageScore: obj.language_score ?? obj.languageScore ?? 0,
overallScore: obj.overall_score ?? obj.overallScore ?? 0,
passThreshold: obj.pass_threshold ?? obj.passThreshold ?? 75,
revisionNotes: Array.isArray(obj.revision_notes)
? obj.revision_notes.map((x: unknown) => normalizeReviewIssue(x as Record<string, unknown>))
: Array.isArray(obj.revisionNotes)
? obj.revisionNotes
: [],
};
}
function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent> {
const out: Record<string, unknown> = { ...parsed };
// 通用字段
if ('agent_name' in out) { out.agentName = out.agent_name; }
if ('pause_point' in out) { out.pausePoint = out.pause_point; }
// materials 数组(materials_ready 和 await_user 里都可能有)
if (Array.isArray(out.materials)) {
out.materials = out.materials.map((m: unknown) =>
normalizeMaterial(m as Record<string, unknown>)
);
}
// outline
if (out.outline) out.outline = normalizeOutline(out.outline);
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
if ('revision_count' in out) out.revisionCount = out.revision_count;
// review_ready
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
// review_item_done (streaming per-category result from sse_queue)
if ('category' in out) out.reviewItemCategory = out.category;
if ('score' in out) out.reviewItemScore = out.score;
if ('issues' in out && Array.isArray(out.issues)) {
out.reviewItemIssues = out.issues.map((x: unknown) =>
normalizeReviewIssue(x as Record<string, unknown>)
);
}
return out as Partial<ProgressEvent>;
}
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
signal?: AbortSignal,
): () => void {
let stopped = false;
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
.then(async (response) => {
if (!response.ok || !response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentType = '';
let currentData = '';
while (!stopped) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
currentData = line.slice(6).trim();
} else if (line === '' && currentType && currentData) {
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
try {
const parsed = JSON.parse(currentData);
const normalized = normalizeEvent(parsed);
onEvent({
type: currentType as ProgressEventType,
sessionId,
message: '',
...normalized,
} as ProgressEvent);
} catch {
// malformed JSON — skip
}
}
currentType = '';
currentData = '';
}
}
}
reader.releaseLock();
})
.catch(() => {});
return () => { stopped = true; };
}
/** 提交用户干预决策,恢复暂停的流程 */
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: payload.action,
approved_material_ids: payload.approvedMaterialIds ?? [],
extra_keywords: payload.extraKeywords ?? [],
edited_outline: payload.editedOutline ?? null,
outline_feedback: payload.outlineFeedback ?? '',
user_revision_notes: payload.userRevisionNotes ?? '',
override_verdict: payload.overrideVerdict ?? false,
selected_issue_indices: payload.selectedIssueIndices ?? null,
}),
});
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
}
+3 -5
View File
@@ -1,7 +1,5 @@
import { apiFetch } from "@/core/api/fetch-client";
import { env } from "@/env";
const BASE = (env.NEXT_PUBLIC_BACKEND_BASE_URL as string | undefined) ?? "http://localhost:8001";
import { getBackendBaseURL } from "@/core/config";
export type RewriteAction = "polish" | "expand" | "shorten" | "tone";
export type RewriteTone =
@@ -12,7 +10,7 @@ export type RewriteTone =
| "expressive";
export interface RewriteRequest {
threadId: string;
threadId?: string | null;
selection: { from: number; to: number; text: string };
action: RewriteAction;
tone?: RewriteTone;
@@ -71,7 +69,7 @@ export async function* streamRewrite(
req: RewriteRequest,
signal?: AbortSignal,
): AsyncGenerator<RewriteStreamEvent> {
const res = await apiFetch(`${BASE}/api/writing/rewrite`, {
const res = await apiFetch(`${getBackendBaseURL()}/api/writing/rewrite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
@@ -0,0 +1,59 @@
import { FileText, PenLine } from 'lucide-react';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
import type { CanvasArtifactText } from '../../types/artifact';
function DraftWaitingPlaceholder() {
return (
<div className="h-full flex flex-col items-center justify-center gap-5 select-none">
<div className="relative">
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center">
<FileText className="w-8 h-8 text-muted-foreground/40" />
</div>
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<PenLine className="w-3.5 h-3.5 text-primary/60" />
</div>
</div>
<div className="text-center space-y-1.5 max-w-[200px]">
<p className="text-sm font-medium text-foreground/60">草稿将在此处显示</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
完成写作流程后,AI 生成的内容将实时出现在这里
</p>
</div>
</div>
);
}
export function AIWritingDraftPanel() {
const { artifact } = useCanvasGraphContext();
if (!artifact) {
return (
<div className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
</div>
);
}
const content = getCurrentArtifactContent(artifact);
return (
<div className="h-full flex flex-col overflow-hidden border-l">
{/* 标题栏 */}
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{content?.title ?? '草稿预览'}
</span>
</div>
<div className="flex-1 overflow-hidden">
{content && !isCodeArtifact(content) ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
</div>
</div>
);
}
@@ -0,0 +1,201 @@
import { useEffect, useState } from 'react';
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
}
interface ModelOption {
name: string;
displayName: string;
}
const INPUT_CLASS =
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800');
const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]);
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
// 初始化时加载文章类型
useEffect(() => {
refreshTypes();
}, [refreshTypes]);
// 类型加载完成后:若当前选中值不在列表中则切换为第一项
useEffect(() => {
if (articleTypes.length === 0) return;
const keys = articleTypes.map((t) => t.key);
if (!keys.includes(articleType)) {
const first = articleTypes[0];
setArticleType(first.key);
setWordCountInput(String(first.default_word_count));
}
}, [articleTypes]); // eslint-disable-line react-hooks/exhaustive-deps
// 切换文章类型时自动填入该类型的默认字数
function handleArticleTypeChange(key: string) {
setArticleType(key);
const found = articleTypes.find((t) => t.key === key);
if (found) {
setWordCountInput(String(found.default_word_count));
}
}
useEffect(() => {
apiFetch(`${getBackendBaseURL()}/api/models`)
.then((r) => r.json())
.then((data) => {
const list: ModelOption[] = (data.models ?? []).map(
(m: { name: string; display_name?: string }) => ({
name: m.name,
displayName: m.display_name || m.name,
}),
);
setModels(list);
if (list.length > 0 && !modelName) {
setModelName(list[0].name);
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function handleWordCountBlur() {
const num = parseInt(wordCountInput, 10);
if (isNaN(num) || num < 100) {
setWordCountInput('800');
} else {
setWordCountInput(String(num));
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
articleType,
targetAudience: audience.trim() || '普通读者',
wordCountTarget: wordCount,
modelName: modelName || undefined,
});
}
return (
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
<Textarea
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={4}
className="resize-none text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
<Select
value={articleType}
onValueChange={handleArticleTypeChange}
disabled={disabled || typesLoading}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent>
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
{models.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
<Select
value={modelName}
onValueChange={setModelName}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="选择模型…" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
<PenLine className="size-3.5" />
开始 AI 写作
</Button>
</form>
);
}
@@ -0,0 +1,202 @@
import { useState, useRef, useEffect } from 'react';
import { History, Trash2, Check, X, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
done: { label: '完成', color: 'text-green-500' },
error: { label: '错误', color: 'text-destructive' },
};
function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins} 分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} 小时前`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days} 天前`;
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onRename: (title: string) => void;
}
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
const { label, color } = STATUS_LABELS[session.status] ?? { label: session.status, color: 'text-muted-foreground' };
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
function commitRename() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== session.title) onRename(trimmed);
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') commitRename();
else if (e.key === 'Escape') { setEditValue(session.title); setEditing(false); }
}
return (
<div
className={cn(
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
)}
onClick={!editing ? onSelect : undefined}
>
<div className="flex-1 min-w-0">
{editing ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
className="flex-1 text-xs bg-background border rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-ring"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<button className="shrink-0 text-green-500 hover:text-green-600" onClick={commitRename}>
<Check className="w-3 h-3" />
</button>
<button className="shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setEditValue(session.title); setEditing(false); }}>
<X className="w-3 h-3" />
</button>
</div>
) : (
<p
className="text-xs font-medium truncate leading-snug"
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true); }}
title="双击重命名"
>
{session.title}
</p>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
</div>
</div>
{/* 删除按钮,hover 时显示 */}
{!editing && (
<button
className="opacity-0 group-hover:opacity-100 shrink-0 p-0.5 text-muted-foreground hover:text-destructive transition-opacity"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
title="删除"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
);
}
interface AIWritingHistoryDropdownProps {
sessions: AIWritingSessionSummary[];
selectedId: string | null;
loading: boolean;
onSelect: (session: AIWritingSessionSummary) => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
onRefresh: () => void;
}
export function AIWritingHistoryDropdown({
sessions,
selectedId,
loading,
onSelect,
onDelete,
onRename,
onRefresh,
}: AIWritingHistoryDropdownProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
function handleOpen() {
setOpen((v) => {
if (!v) onRefresh();
return !v;
});
}
return (
<div ref={containerRef} className="relative">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={handleOpen}
title="查看历史记录"
>
<History className="size-3" />
历史
</Button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border bg-popover shadow-lg overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-xs font-semibold">写作历史</span>
<button
className={cn('text-muted-foreground hover:text-foreground transition-transform', loading && 'animate-spin')}
onClick={onRefresh}
title="刷新"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
{/* 列表 */}
<div className="max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<div className="text-center py-6 text-xs text-muted-foreground">
{loading ? '加载中…' : '暂无历史记录'}
</div>
) : (
sessions.map((s) => (
<SessionItem
key={s.id}
session={s}
isSelected={s.id === selectedId}
onSelect={() => { onSelect(s); setOpen(false); }}
onDelete={() => onDelete(s.id)}
onRename={(title) => onRename(s.id, title)}
/>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,131 @@
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
import { AIWritingTimeline } from './AIWritingTimeline';
const PAUSE_LABELS: Record<string, string> = {
material_confirm: '素材确认',
outline_confirm: '大纲确认',
draft_confirm: '草稿确认',
review_confirm: '编辑意见处理',
};
const ACTION_LABELS: Record<string, string> = {
approve: '已批准',
re_search: '重新搜索',
re_outline: '重新规划',
finalize: '直接定稿',
to_editor: '提交审核',
user_revise: '手动修改',
accept_review: '接受意见',
force_finalize: '强制定稿',
};
const PAUSE_ICONS: Record<string, React.ElementType> = {
material_confirm: Search,
outline_confirm: PenLine,
draft_confirm: FileText,
review_confirm: ClipboardEdit,
};
function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) {
const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2;
const ts = record.timestamp
? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: '';
return (
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="w-3 h-3 text-primary" />
</div>
{!isLast && <div className="w-px flex-1 bg-border/60 mt-1" />}
</div>
<div className="pb-4 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
{PAUSE_LABELS[record.pausePoint] ?? record.pausePoint}
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-primary font-medium">
{ACTION_LABELS[record.action] ?? record.action}
</span>
</div>
{ts && <p className="text-[10px] text-muted-foreground mt-0.5">{ts}</p>}
</div>
</div>
);
}
interface AIWritingHistoryViewProps {
session: AIWritingSessionSummary;
}
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
const interventions = session.completed_interventions ?? [];
const transcript = session.transcript;
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0;
const createdAt = session.created_at
? new Date(session.created_at).toLocaleString('zh-CN', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
: '';
return (
<div className="flex-1 overflow-y-auto">
{/* 元信息 */}
<div className="px-4 pt-3">
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
{session.user_intent && (
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{session.user_intent}
</p>
)}
{createdAt && (
<p className="text-[10px] text-muted-foreground">{createdAt}</p>
)}
</div>
</div>
{hasTranscript ? (
/* 完整对话时间线回显 */
<AIWritingTimeline
events={transcript!.progressEvents}
completedInterventions={transcript!.completedInterventions ?? []}
currentPause={null}
/>
) : (
/* 旧会话无时间线快照时,回退到精简阶段列表 */
<div className="px-4 py-3 space-y-4">
{interventions.length > 0 ? (
<div>
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
<div>
{interventions.map((rec, i) => (
<InterventionTimelineItem
key={i}
record={rec}
isLast={i === interventions.length - 1}
/>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
)}
{session.status === 'done' && (
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<Star className="w-3.5 h-3.5" />
<span>写作完成</span>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { getCurrentArtifactContent } from '../../utils/artifact';
export function AIWritingPageHeader() {
const navigate = useNavigate();
const { artifact } = useCanvasGraphContext();
const currentContent = getCurrentArtifactContent(artifact);
return (
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate('/page/canvas')}
title="返回 Canvas 列表"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<PenLine className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold">AI 智能写作</span>
{currentContent?.title && (
<>
<Separator orientation="vertical" className="h-4" />
<span className="text-sm text-muted-foreground truncate">
{currentContent.title}
</span>
</>
)}
</div>
);
}
@@ -0,0 +1,388 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWriting';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
import { AIWritingHistoryView } from './AIWritingHistoryView';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABEL: Record<string, string> = {
idle: '就绪',
researching: '素材收集专家搜集素材中…',
awaiting_material_confirm: '等待确认素材',
writing_outline: '作家规划大纲中…',
awaiting_outline_confirm: '等待确认大纲',
writing_draft: '作家写作中…',
awaiting_draft_confirm: '等待确认草稿',
reviewing: '编辑审核中…',
awaiting_review_confirm: '等待处理编辑意见',
revising: '按意见修改中…',
done: '写作完成 ✓',
error: '出错了',
};
export function AIWritingPanel() {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
// History view state — null means normal writing mode
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
const isIdle = state.status === 'idle';
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
const isAutoScrollingRef = useRef(false);
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
const autoScrollRef = useRef(true);
const [autoScroll, setAutoScroll] = useState(true);
const disableAutoScroll = useCallback(() => {
autoScrollRef.current = false;
setAutoScroll(false);
}, []);
// 新会话 / 重置后恢复自动滚底
useEffect(() => {
if (isIdle) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isIdle]);
// 开始写作 / 点击继续后恢复自动滚底
useEffect(() => {
if (isRunning && !state.isPaused) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isRunning, state.isPaused]);
// 监听用户手动滚动(挂载一次)
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
};
// scroll 事件:兼容拖动滚动条的场景
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
let lastTop = el.scrollTop;
const onScroll = () => {
const cur = el.scrollTop;
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
disableAutoScroll();
}
lastTop = cur;
};
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('scroll', onScroll);
};
}, [disableAutoScroll]);
// 内容变化时若自动滚底开启则滚到底部
useEffect(() => {
// 检查同步 ref,避免 state 异步导致的时序竞争
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
const el = scrollRef.current;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
}, [
autoScroll, // 点击按钮后重新触发一次
isRunning,
state.progressEvents.length,
state.streamingDraftSections.length,
state.streamingReviewItems.length,
state.outlineStreamingText,
state.currentPause,
]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
setHistorySession(session);
// Push historical draft to the right panel
if (session.draft_markdown) {
setArtifact({
currentIndex: 0,
contents: [{
index: 0,
type: 'text',
title: session.draft_title ?? session.title,
fullMarkdown: session.draft_markdown,
}],
});
} else {
setArtifact(null);
}
}, [setArtifact]);
const handleNewSession = useCallback(() => {
setHistorySession(null);
reset();
}, [reset]);
const showNewButton = historySession !== null || state.status !== 'idle';
// History view mode
if (historySession) {
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
{historySession.draft_title ?? historySession.title}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建对话
</Button>
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={historySession.id}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
<AIWritingHistoryView session={historySession} />
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold shrink-0">AI 智能写作</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
{showNewButton && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建
</Button>
)}
{isRunning && !state.isPaused && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={pauseWriting}
>
<Square className="size-3 fill-current" />
停止
</Button>
)}
{state.isPaused && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
onClick={resumeWriting}
>
<Play className="size-3 fill-current" />
继续
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
</>
)}
{(isDone || isError) && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
)}
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={null}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm onSubmit={startWriting} />
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
<AIWritingTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
currentInterventionSlot={
state.currentPause ? (
<InterventionCard
pauseEvent={state.currentPause}
materials={state.materials}
outline={state.outline}
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
revisionCount={state.revisionCount}
onSubmit={submitIntervention}
disabled={false}
/>
) : undefined
}
/>
)}
{/* 大纲流式思考框 */}
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
)}
{/* 编辑审核流式结果 */}
{state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
<ReviewStreamingView items={state.streamingReviewItems} />
)}
{/* 修改完成摘要 */}
{state.revisionSummary && (
<div className="px-4 pb-2">
<RevisionSummaryCard summary={state.revisionSummary} />
</div>
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
)}
{showSkeleton &&
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
</div>
)}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
</div>
)}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
</div>
)}
</div>{/* 滚动内容区结束 */}
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
{isRunning && !autoScroll && (
<div className="absolute inset-x-0 bottom-4 flex justify-center z-10 pointer-events-none">
<button
onClick={scrollToBottom}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-full bg-background border shadow-md hover:bg-muted transition-colors whitespace-nowrap"
>
<ChevronsDown className="size-3.5" />
回到底部
</button>
</div>
)}
</div>{/* 相对定位容器结束 */}
{/* 状态栏 */}
{!isIdle && (
<div
className={cn(
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{state.isPaused ? (
<>
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
已暂停
</>
) : (
<>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{state.status === 'researching' && state.researchProgress
? state.researchProgress
: STATUS_LABEL[state.status] ?? state.status}
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
import { CompletedInterventionCard } from './CompletedInterventionCard';
interface AIWritingTimelineProps {
events: ProgressEvent[];
completedInterventions: CompletedIntervention[];
currentPause: ProgressEvent | null;
currentInterventionSlot?: React.ReactNode;
}
const DISPLAYED_TYPES = new Set([
'step_start',
'step_done',
'materials_ready',
'outline_ready',
'draft_ready',
'review_ready',
'await_user',
'resumed',
'done',
'error',
]);
export function AIWritingTimeline({
events,
completedInterventions,
currentPause,
currentInterventionSlot,
}: AIWritingTimelineProps) {
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
if (visible.length === 0) return null;
// 遍历时追踪 await_user 事件的序号,以便从 completedInterventions 取对应记录
let awaitUserIdx = 0;
return (
<div className="px-4 py-3 space-y-0">
{visible.map((event, idx) => {
const isLast = idx === visible.length - 1;
let slot: React.ReactNode = null;
if (event.type === 'await_user') {
const completedRecord = completedInterventions[awaitUserIdx];
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 渲染折叠历史卡
slot = <CompletedInterventionCard record={completedRecord} />;
} else if (currentPause && isLast) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
}
}
return (
<TimelineStep key={idx} event={event} isLast={isLast && !slot}>
{slot}
</TimelineStep>
);
})}
</div>
);
}
@@ -0,0 +1,225 @@
import { useState } from 'react';
import {
CheckCircle2, ChevronRight, ChevronDown,
ExternalLink, ListTree, FileText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CompletedIntervention } from '../../types/ai-writing';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
import { ScoreBar } from './ScoreBar';
interface Props {
record: CompletedIntervention;
}
export function CompletedInterventionCard({ record }: Props) {
const [expanded, setExpanded] = useState(false);
if (record.pausePoint === 'material_confirm') {
return <MaterialCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'outline_confirm') {
return <OutlineCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'draft_confirm') {
return <DraftCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'review_confirm') {
return <ReviewCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
return null;
}
// ── 素材确认历史卡 ────────────────────────────────────────────────────────────
function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const count = record.materials?.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1 font-medium">
已确认{' '}
<span className="text-muted-foreground font-normal">{count} 条素材</span>
</span>
{count > 0 && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.materials && record.materials.length > 0 && (
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
{record.materials.map((m) => {
const pct = Math.round(m.relevanceScore * 100);
const barColor =
m.relevanceScore >= 0.8 ? 'bg-green-500'
: m.relevanceScore >= 0.5 ? 'bg-orange-400'
: 'bg-red-400';
return (
<div key={m.id} className="rounded border border-border/50 bg-background px-2.5 py-1.5 space-y-1">
<p className="font-medium leading-snug line-clamp-2">{m.title}</p>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="truncate max-w-[100px]">{m.source}</span>
{m.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{m.publishedAt}</span></>}
<div className="flex flex-1 items-center gap-1 min-w-0">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
</div>
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
</div>
{m.url && (
<a href={m.url} target="_blank" rel="noopener noreferrer"
className="shrink-0 text-primary hover:underline inline-flex items-center gap-0.5">
原文 <ExternalLink className="size-2.5" />
</a>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ── 大纲确认历史卡 ────────────────────────────────────────────────────────────
function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const sectionCount = record.outline?.sections.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="flex-1 font-medium">
已确认大纲
{record.outline?.title && (
<span className="ml-1 text-muted-foreground font-normal">
《{record.outline.title}》
</span>
)}
<span className="ml-1 text-muted-foreground font-normal">共 {sectionCount} 章节</span>
</span>
{record.outline && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.outline && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
<OutlineCard outline={record.outline} readOnly />
</div>
)}
</div>
);
}
// ── 草稿确认历史卡 ────────────────────────────────────────────────────────────
function DraftCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const title = record.draftTitle ? `《${record.draftTitle}》` : '草稿';
const wordCount = record.draftWordCount ?? 0;
const hasReview = !!record.reviewResult;
const actionLabel =
record.action === 'finalize' ? '已确认定稿' :
record.action === 'user_revise' ? '已要求修改' :
'已提交编辑审核';
const actionColor =
record.action === 'finalize' ? 'text-green-600 dark:text-green-400' :
record.action === 'user_revise' ? 'text-orange-500 dark:text-orange-400' :
'text-blue-500 dark:text-blue-400';
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium shrink-0', actionColor)}>{actionLabel}</span>
<span className="text-muted-foreground flex-1 truncate">
{title}
{wordCount > 0 && <span className="ml-1">({wordCount} 字)</span>}
</span>
{hasReview && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.reviewResult && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
<ReviewCard review={record.reviewResult} />
</div>
)}
</div>
);
}
// ── 编辑审核确认历史卡 ────────────────────────────────────────────────────────
function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const isForce = record.action === 'force_finalize';
const hasReview = !!record.reviewResult;
const actionLabel = isForce ? '已强制定稿(忽略编辑意见)' : '已采纳编辑意见,重新写作';
const actionColor = isForce ? 'text-orange-500 dark:text-orange-400' : 'text-blue-500 dark:text-blue-400';
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium flex-1', actionColor)}>{actionLabel}</span>
{hasReview && record.reviewResult && (
<span className="text-muted-foreground tabular-nums shrink-0">
{record.reviewResult.overallScore} / 100
</span>
)}
{hasReview && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.reviewResult && (
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StreamingDraftSection } from '../../types/ai-writing';
interface DraftStreamingViewProps {
title: string;
sections: StreamingDraftSection[];
}
function TypewriterCursor() {
return (
<span
aria-hidden
className="inline-block w-0.5 h-4 bg-foreground align-middle ml-0.5 animate-[blink_1s_step-end_infinite]"
style={{ verticalAlign: 'text-bottom' }}
/>
);
}
function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDone: boolean }) {
const [open, setOpen] = useState(true);
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (open && preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [thinkingText, open]);
if (!thinkingText) return null;
return (
<div className="mb-2 rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
<span className="ml-auto text-amber-400">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
</button>
{open && (
<pre
ref={preRef}
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-32 overflow-y-auto italic"
>
{thinkingText}
{!isDone && (
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
);
}
export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [sections]);
const activeSectionIdx = sections.reduce(
(max, s) => (!s.isDone ? Math.max(max, s.sectionIndex) : max),
-1,
);
if (sections.length === 0) return null;
return (
<div className="px-4 pb-4">
<div className="rounded-xl border bg-card shadow-sm p-4 prose prose-sm dark:prose-invert max-w-none">
{title && (
<h1 className="text-base font-bold text-center mb-4 text-foreground">{title}</h1>
)}
{sections.map((sec) => (
<div key={sec.sectionIndex} className="mb-4">
<h2 className={cn(
'text-sm font-semibold mb-1.5',
sec.isDone ? 'text-foreground' : 'text-purple-500',
)}>
{sec.sectionTitle}
</h2>
{/* 该章节的思考过程(可折叠) */}
<SectionThinking thinkingText={sec.thinkingText} isDone={sec.isDone} />
{/* 正文内容 */}
<p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0">
{sec.text}
{sec.sectionIndex === activeSectionIdx && !sec.isDone && <TypewriterCursor />}
</p>
</div>
))}
<div ref={bottomRef} />
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,491 @@
import { useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
ProgressEvent, UserInterventionPayload,
} from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
interface InterventionCardProps {
pauseEvent: ProgressEvent;
materials: AIMaterial[];
outline: AIOutline | null;
reviewResult: ReviewResult | null;
draftTitle: string;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
}
if (pausePoint === 'outline_confirm') {
return (
<OutlineConfirmCard
outline={outline ?? pauseEvent.outline ?? null}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'draft_confirm') {
return (
<DraftConfirmCard
draftTitle={draftTitle}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmCard
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
revisionCount={revisionCount}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
return (
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
等待用户确认({pausePoint})…
</div>
);
}
// ── 工具函数 ──────────────────────────────────────────────────────────────────
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
return new Set(issues.map((_, i) => i));
}
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
const selected = issues.filter((_, i) => selectedIndices.has(i));
if (selected.length === 0) return '';
return selected
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
.join('\n');
}
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
interface MaterialConfirmCardProps {
materials: AIMaterial[];
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
setSubmitting(false);
}
const hasExtra = extraKeywords.length > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
</p>
</div>
</div>
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}} />
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
/>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
</div>
);
}
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
interface OutlineConfirmCardProps {
outline: AIOutline | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [showReplan, setShowReplan] = useState(false);
const [feedback, setFeedback] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({
action: 'confirm',
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
});
setSubmitting(false);
}
async function handleReplan() {
setSubmitting(true);
await onSubmit({ action: 're_outline', outlineFeedback: feedback.trim() });
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">请确认大纲后继续写作</p>
<p className="text-xs text-muted-foreground mt-0.5">
可直接点击章节标题或要点进行编辑,或要求重新规划大纲。
</p>
</div>
</div>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
{showReplan && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">重新规划意见(可选)</label>
<textarea
rows={2} value={feedback} onChange={(e) => setFeedback(e.target.value)}
placeholder="描述大纲调整方向,例如:增加案例分析章节…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
{showReplan ? (
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReplan} disabled={disabled || submitting}
>
<RefreshCw className="size-3.5" />提交重规划
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => setShowReplan(true)} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新规划…
</Button>
)}
</div>
</div>
);
}
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
interface DraftConfirmCardProps {
draftTitle: string;
revisionCount: number;
reviewResult: ReviewResult | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
const [notes, setNotes] = useState('');
const [showNotes, setShowNotes] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
setSubmitting(true);
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
setSubmitting(false);
}
async function handleApplyReview() {
if (!reviewResult) return;
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
setSubmitting(true);
await onSubmit({
action: 'user_revise',
userRevisionNotes: formattedNotes,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容
</p>
</div>
</div>
{/* 编辑审核结果(若已审核,支持勾选意见) */}
{reviewResult && (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
)}
{/* 用户自定义修改意见 */}
{showNotes && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">您的修改意见</label>
<textarea
rows={2} value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="描述希望修改的内容,例如:第二段数据来源不清晰,结尾过于仓促…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex flex-col gap-1.5 pt-1">
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
{hasReview && (
<Button
type="button" variant="outline" size="sm"
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
onClick={handleApplyReview}
disabled={disabled || submitting || selectedIssueIndices.size === 0}
>
<ClipboardEdit className="size-3.5" />
按编辑审核意见修改
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
)}
<div className="flex gap-1.5">
{showNotes ? (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('user_revise')} disabled={disabled || submitting || !notes.trim()}
>
<PenLine className="size-3.5" />按我的意见修改
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => setShowNotes(true)} disabled={disabled || submitting}
>
<PenLine className="size-3.5" />给出修改意见…
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
</div>
</div>
</div>
);
}
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
interface ReviewConfirmCardProps {
reviewResult: ReviewResult | null;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
const [extraNotes, setExtraNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function handleAccept() {
setSubmitting(true);
const issueNotes = reviewResult
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
: '';
const combined = [issueNotes, extraNotes.trim()].filter(Boolean).join('\n\n');
await onSubmit({
action: 'accept_review',
userRevisionNotes: combined || undefined,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
async function handleForce() {
setSubmitting(true);
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
setSubmitting(false);
}
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">编辑认为文章需要修改</p>
<p className="text-xs text-muted-foreground mt-0.5">
勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。
</p>
</div>
</div>
{reviewResult ? (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
onToggleIssue={hasIssues ? toggleIssue : undefined}
/>
) : (
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
)}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充修改指令(可选)</label>
<textarea
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
placeholder="留空则只采纳已勾选意见;或在此补充您自己的修改方向…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleAccept}
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0 && !extraNotes.trim())}
>
<ClipboardEdit className="size-3.5" />按编辑意见修改
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { cn } from '@/lib/utils';
import type { ReviewIssue } from '../../types/ai-writing';
interface IssueListProps {
issues: ReviewIssue[];
selectedIndices?: Set<number>;
onToggle?: (index: number) => void;
}
const CATEGORY_LABEL: Record<string, string> = {
fact: '事实',
logic: '逻辑',
language: '语言',
};
const CATEGORY_COLOR: Record<string, string> = {
fact: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
logic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400',
language: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
};
export function IssueList({ issues, selectedIndices, onToggle }: IssueListProps) {
if (issues.length === 0) {
return <p className="text-xs text-muted-foreground text-center py-1">无需修改意见</p>;
}
const indexed = issues.map((issue, i) => ({ issue, index: i }));
const major = indexed.filter(({ issue }) => issue.severity === 'major');
const minor = indexed.filter(({ issue }) => issue.severity === 'minor');
const selectable = selectedIndices !== undefined && onToggle !== undefined;
return (
<div className="space-y-3">
{major.length > 0 && (
<IssueGroup
label="必须修改"
labelClass="text-red-500"
items={major}
selectable={selectable}
selectedIndices={selectedIndices}
onToggle={onToggle}
/>
)}
{minor.length > 0 && (
<IssueGroup
label="建议修改"
labelClass="text-yellow-500"
items={minor}
selectable={selectable}
selectedIndices={selectedIndices}
onToggle={onToggle}
/>
)}
</div>
);
}
interface IssueGroupProps {
label: string;
labelClass: string;
items: { issue: ReviewIssue; index: number }[];
selectable: boolean;
selectedIndices?: Set<number>;
onToggle?: (index: number) => void;
}
function IssueGroup({ label, labelClass, items, selectable, selectedIndices, onToggle }: IssueGroupProps) {
return (
<div className="space-y-1.5">
<p className={cn('text-xs font-semibold', labelClass)}>{label}</p>
{items.map(({ issue, index }) => {
const isSelected = !selectable || (selectedIndices?.has(index) ?? true);
return (
<div
key={index}
className={cn(
'rounded border bg-background px-2.5 py-2 space-y-1 text-xs transition-opacity',
selectable ? 'cursor-pointer' : '',
selectable && !isSelected ? 'opacity-40 border-border/30' : 'border-border/60',
)}
onClick={selectable ? () => onToggle?.(index) : undefined}
>
<div className="flex items-center gap-1.5">
{selectable && (
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggle?.(index)}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-primary cursor-pointer"
/>
)}
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', CATEGORY_COLOR[issue.category] ?? 'bg-muted text-muted-foreground')}>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<span className="text-muted-foreground truncate">{issue.location}</span>
</div>
<p className={cn('text-foreground leading-snug', selectable ? 'pl-5' : '')}>
{issue.issue}
</p>
<p className={cn('text-muted-foreground leading-snug', selectable ? 'pl-5' : '')}>
<span className="text-primary mr-1">→</span>
{issue.suggestion}
</p>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialItemProps {
material: AIMaterial;
checked: boolean;
onToggle: (id: string) => void;
}
function relevanceColor(score: number): string {
if (score >= 0.8) return 'bg-green-500';
if (score >= 0.5) return 'bg-orange-400';
return 'bg-red-400';
}
function relevanceLabel(score: number): string {
if (score >= 0.8) return '高相关';
if (score >= 0.5) return '中相关';
return '低相关';
}
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
const [expanded, setExpanded] = useState(false);
const pct = Math.round(material.relevanceScore * 100);
return (
<div
className={cn(
'rounded-md border text-xs transition-colors',
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
)}
>
{/* 头部行 */}
<div className="flex items-start gap-2 px-2.5 py-2">
{/* Checkbox */}
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(material.id)}
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-input accent-primary cursor-pointer"
/>
{/* 标题 + 展开按钮 */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex flex-1 items-start gap-1.5 text-left"
>
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
{material.title}
</span>
{expanded
? <ChevronDown className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />}
</button>
</div>
{/* 元信息行 */}
<div className="flex items-center gap-2 px-2.5 pb-2 pl-8">
<span className="text-muted-foreground truncate max-w-[120px]">{material.source}</span>
{material.publishedAt && (
<>
<span className="text-muted-foreground/40">·</span>
<span className="text-muted-foreground shrink-0">{material.publishedAt}</span>
</>
)}
{/* 相关度色条 */}
<div className="flex flex-1 items-center gap-1 min-w-0">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
<div
className={cn('h-full rounded-full transition-all', relevanceColor(material.relevanceScore))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn('shrink-0', relevanceColor(material.relevanceScore).replace('bg-', 'text-'))}>
{pct}%
</span>
<span className="text-muted-foreground/60 shrink-0">({relevanceLabel(material.relevanceScore)})</span>
</div>
</div>
{/* 展开内容 */}
{expanded && (
<div className="px-2.5 pb-2.5 pl-8 space-y-1.5 border-t">
<p className="text-muted-foreground leading-relaxed pt-2 line-clamp-6">
{material.content.slice(0, 300)}
{material.content.length > 300 && '…'}
</p>
{material.url && (
<a
href={material.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
查看原文
<ExternalLink className="size-2.5" />
</a>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, FileText } from 'lucide-react';
import type { AIMaterial } from '../../types/ai-writing';
import { MaterialItem } from './MaterialItem';
interface MaterialsCardProps {
materials: AIMaterial[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
}
export function MaterialsCard({ materials, selectedIds, onToggle }: MaterialsCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 折叠头 */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-muted/40 transition-colors text-left"
>
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1">
素材列表
<span className="ml-1 text-muted-foreground font-normal">
(共 {materials.length} 条,已选 {selectedIds.size} 条)
</span>
</span>
{expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />}
</button>
{/* 列表 */}
{expanded && (
<div className="px-2.5 pb-2.5 space-y-1.5 border-t">
{materials.length === 0 ? (
<p className="text-xs text-muted-foreground py-2 text-center">暂无素材</p>
) : (
materials.map((m) => (
<MaterialItem
key={m.id}
material={m}
checked={selectedIds.has(m.id)}
onToggle={onToggle}
/>
))
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialsToolbarProps {
materials: AIMaterial[];
selectedIds: Set<string>;
onSelectAll: () => void;
onDeselectAll: () => void;
extraKeywords: string[];
onAddKeyword: (kw: string) => void;
onRemoveKeyword: (kw: string) => void;
}
export function MaterialsToolbar({
materials,
selectedIds,
onSelectAll,
onDeselectAll,
extraKeywords,
onAddKeyword,
onRemoveKeyword,
}: MaterialsToolbarProps) {
const [kwInput, setKwInput] = useState('');
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
function handleAddKeyword() {
const kw = kwInput.trim();
if (!kw || extraKeywords.includes(kw)) return;
onAddKeyword(kw);
setKwInput('');
}
return (
<div className="space-y-2">
{/* 全选/取消 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={allSelected ? onDeselectAll : onSelectAll}
className="text-xs text-primary hover:underline"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
{/* 补充关键词 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
<div className="flex gap-1.5">
<input
type="text"
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
placeholder="输入关键词后按 Enter 或点击 +"
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handleAddKeyword}
disabled={!kwInput.trim()}
>
<Plus className="size-3" />
</Button>
</div>
{/* 已添加关键词 Tag 列表 */}
{extraKeywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{extraKeywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
onClick={() => onRemoveKeyword(kw)}
className="hover:text-destructive leading-none"
>
×
</button>
</span>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,143 @@
import { useState } from 'react';
import { ListTree, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIOutline, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { OutlineSection } from './OutlineSection';
interface OutlineCardProps {
outline: AIOutline;
onChange?: (updated: AIOutline) => void;
readOnly?: boolean;
}
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
const [collapsed, setCollapsed] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(outline.title);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const editable = !readOnly && !!onChange;
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== outline.title) {
onChange?.({ ...outline, title: trimmed });
} else {
setTitleDraft(outline.title);
}
}
function updateSection(i: number, updated: OutlineSectionType) {
const sections = [...outline.sections];
sections[i] = updated;
onChange?.({ ...outline, sections });
}
function removeSection(i: number) {
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
}
function reorderSections(from: number, to: number) {
if (from === to) return;
const sections = [...outline.sections];
const [moved] = sections.splice(from, 1);
sections.splice(to, 0, moved);
onChange?.({ ...outline, sections });
}
function addSection() {
onChange?.({
...outline,
sections: [...outline.sections, { sectionTitle: '', keyPoints: [], materialIds: [] }],
});
}
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 头部 */}
<div className="flex items-center gap-2 px-3 py-2 border-b">
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium flex-1">文章大纲</span>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground"
>
{collapsed ? '展开' : '收起'}
</button>
</div>
{!collapsed && (
<div className="px-3 py-2.5 space-y-2">
{/* 文章标题 */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground shrink-0">标题:</span>
{editingTitle && editable ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(outline.title); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-semibold"
/>
) : (
<span
className={editable ? 'flex-1 font-semibold cursor-text hover:text-primary' : 'flex-1 font-semibold'}
onClick={() => editable && setEditingTitle(true)}
>
{outline.title || <span className="text-muted-foreground italic font-normal">(未设置标题)</span>}
</span>
)}
</div>
{/* 章节列表 */}
<div className="space-y-1.5">
{outline.sections.map((section, i) => (
<OutlineSection
key={i}
section={section}
index={i}
onChange={(updated) => updateSection(i, updated)}
onRemove={editable ? () => removeSection(i) : undefined}
readOnly={!editable}
isDragging={dragIndex === i}
isOver={overIndex === i && dragIndex !== null && dragIndex !== i}
onDragStart={() => setDragIndex(i)}
onDragEnter={() => setOverIndex(i)}
onDrop={() => {
if (dragIndex !== null && dragIndex !== i) reorderSections(dragIndex, i);
setDragIndex(null);
setOverIndex(null);
}}
onDragEnd={() => {
setDragIndex(null);
setOverIndex(null);
}}
/>
))}
</div>
{/* 添加章节 */}
{editable && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full h-7 text-xs gap-1.5 text-muted-foreground"
onClick={addSection}
>
<Plus className="size-3" />
添加章节
</Button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, Plus, X, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
interface OutlineSectionProps {
section: OutlineSectionType;
index: number;
onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void;
readOnly?: boolean;
// Drag-and-drop
isDragging?: boolean;
isOver?: boolean;
onDragStart?: () => void;
onDragEnter?: () => void;
onDrop?: () => void;
onDragEnd?: () => void;
}
export function OutlineSection({
section, index, onChange, onRemove, readOnly,
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
}: OutlineSectionProps) {
const [expanded, setExpanded] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== section.sectionTitle) {
onChange({ ...section, sectionTitle: trimmed });
} else {
setTitleDraft(section.sectionTitle);
}
}
function updateKeyPoint(i: number, value: string) {
const next = [...section.keyPoints];
next[i] = value;
onChange({ ...section, keyPoints: next });
}
function removeKeyPoint(i: number) {
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
}
function addKeyPoint() {
onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
}
return (
<div
draggable={!readOnly}
onDragStart={(e) => { e.stopPropagation(); onDragStart?.(); }}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onDragEnter?.(); }}
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onDrop?.(); }}
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
className={cn(
'rounded border bg-background text-xs transition-all duration-150 select-none',
isDragging ? 'opacity-40 border-border/60' : isOver ? 'border-blue-400 ring-1 ring-blue-400/40' : 'border-border/60',
)}
>
{/* 章节头 */}
<div className="flex items-center gap-1.5 px-2.5 py-2">
{!readOnly && (
<GripVertical className="size-3 text-muted-foreground/40 shrink-0 cursor-grab active:cursor-grabbing" />
)}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
>
{expanded
? <ChevronDown className="size-3.5 text-muted-foreground" />
: <ChevronRight className="size-3.5 text-muted-foreground" />}
</button>
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
/>
) : (
<span
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
</span>
)}
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
{!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
{/* 要点列表 */}
{expanded && (
<div className="px-8 pb-2.5 space-y-1 border-t">
{section.keyPoints.map((kp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1.5">
<span className="text-muted-foreground/40 shrink-0">•</span>
{readOnly ? (
<span className="flex-1 text-muted-foreground">{kp}</span>
) : (
<input
value={kp}
onChange={(e) => updateKeyPoint(i, e.target.value)}
placeholder="输入要点…"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加要点
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react';
import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface OutlineStreamingViewProps {
text: string;
thinkingText?: string;
}
export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) {
thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}
}, [thinkingText, thinkOpen]);
if (!text && !thinkingText) return null;
return (
<div className="px-4 pb-2 space-y-2">
{/* 思考过程区(仅在有思考内容时展示) */}
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
思考过程
</span>
<span className="ml-auto text-amber-400">
{thinkOpen
? <ChevronDown className="size-3.5" />
: <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{thinkingText}
{/* 思考仍在进行时显示光标 */}
{!text && (
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
{/* 正文流式区 */}
{text && (
<div className="rounded-xl border border-purple-200 dark:border-purple-900 bg-purple-50/50 dark:bg-purple-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200/60 dark:border-purple-900/60 bg-purple-100/40 dark:bg-purple-900/30">
<FileText className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
作家正在规划大纲…
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-purple-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
>
{text}
<span className="inline-block w-0.5 h-3.5 bg-purple-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,74 @@
import { cn } from '@/lib/utils';
import type { ReviewResult } from '../../types/ai-writing';
import { ScoreBar } from './ScoreBar';
import { IssueList } from './IssueList';
interface ReviewCardProps {
review: ReviewResult;
revisionCount?: number;
selectedIndices?: Set<number>;
onToggleIssue?: (index: number) => void;
}
export function ReviewCard({ review, revisionCount, selectedIndices, onToggleIssue }: ReviewCardProps) {
const isPassed = review.verdict === 'pass';
return (
<div className="rounded-md border bg-muted/20 overflow-hidden text-xs">
{/* 头部:综合评分 + 轮次 */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<div className="flex items-center gap-2">
<span className="font-medium">编辑审核结果</span>
{revisionCount !== undefined && revisionCount > 0 && (
<span className="text-muted-foreground">(第 {revisionCount} 次修改)</span>
)}
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-[10px] font-semibold',
isPassed
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
)}
>
{isPassed ? '通过' : '打回'}
</span>
<span className="font-semibold tabular-nums">
{review.overallScore}
<span className="text-muted-foreground font-normal"> / 100</span>
</span>
</div>
</div>
{/* 分项评分 */}
<div className="px-3 py-2.5 space-y-2 border-b">
<ScoreBar label="事实准确性" score={review.factScore} />
<ScoreBar label="逻辑结构" score={review.logicScore} />
<ScoreBar label="语言文字" score={review.languageScore} />
<p className="text-muted-foreground/60 text-[10px]">
通过线:{review.passThreshold} 分
</p>
</div>
{/* 修改意见(支持勾选) */}
{review.revisionNotes.length > 0 && (
<div className="px-3 py-2.5">
<div className="flex items-center justify-between mb-2">
<p className="text-muted-foreground font-medium">修改意见</p>
{selectedIndices !== undefined && onToggleIssue && (
<p className="text-[10px] text-muted-foreground">
已选 {selectedIndices.size} / {review.revisionNotes.length}
</p>
)}
</div>
<IssueList
issues={review.revisionNotes}
selectedIndices={selectedIndices}
onToggle={onToggleIssue}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useRef } from 'react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StreamingReviewItem, ReviewIssue } from '../../types/ai-writing';
const CATEGORY_META: Record<
'fact' | 'logic' | 'language',
{ label: string; order: number }
> = {
fact: { label: '事实准确性', order: 0 },
logic: { label: '逻辑结构', order: 1 },
language: { label: '语言文字', order: 2 },
};
const CATEGORIES = (['fact', 'logic', 'language'] as const);
function ScoreBar({ score }: { score: number }) {
const color =
score >= 80 ? 'bg-green-400' :
score >= 60 ? 'bg-amber-400' :
'bg-red-400';
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${score}%` }}
/>
</div>
<span className="text-xs font-semibold tabular-nums w-8 text-right">{score}</span>
</div>
);
}
function IssueRow({ issue }: { issue: ReviewIssue }) {
return (
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5">
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
<span className="text-muted-foreground">{issue.issue}</span>
{issue.suggestion && (
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
)}
</div>
);
}
// 流式打字机文本块(审核进行中的分类显示)
function StreamingText({ item }: { item: StreamingReviewItem }) {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [item.streamingText, item.thinkingText]);
const hasContent = item.streamingText || item.thinkingText;
if (!hasContent) {
return (
<p className="text-[11px] text-muted-foreground pl-5 italic">正在审阅…</p>
);
}
return (
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed mt-1 p-2 rounded bg-muted/40 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto"
>
{item.thinkingText && (
<span className="italic text-amber-600/70 dark:text-amber-400/60">
{item.thinkingText}
</span>
)}
{item.streamingText}
<span className="inline-block w-0.5 h-3 bg-orange-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
);
}
interface ReviewStreamingViewProps {
items: StreamingReviewItem[];
}
export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
if (items.length === 0) return null;
const doneItems = items.filter((it) => it.isDone);
const allDone = doneItems.length === 3;
const overallScore =
doneItems.length > 0
? Math.round(doneItems.reduce((s, it) => s + it.score, 0) / doneItems.length)
: 0;
return (
<div className="px-4 pb-2">
<div className="rounded-xl border border-orange-200 dark:border-orange-900 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden shadow-sm">
{/* 标题栏 */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-200/60 dark:border-orange-900/60 bg-orange-100/40 dark:bg-orange-900/30">
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
编辑正在审核…
</span>
{!allDone && (
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-orange-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
)}
{allDone && (
<span className="ml-auto text-xs text-orange-600 dark:text-orange-400 font-semibold tabular-nums">
综合 {overallScore}
</span>
)}
</div>
{/* 逐项审核结果 */}
<div className="divide-y divide-orange-100 dark:divide-orange-900/50">
{CATEGORIES.map((cat) => {
const meta = CATEGORY_META[cat];
const item = items.find((it) => it.category === cat);
const isDone = item?.isDone ?? false;
const isStreaming = item != null && !item.isDone;
const isPending = item == null;
return (
<div key={cat} className="px-3 py-2 space-y-1.5">
{/* 分类行 */}
<div className="flex items-center gap-2">
{isDone ? (
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
) : isStreaming ? (
<Loader2 className="size-3.5 text-orange-400 animate-spin shrink-0" />
) : (
<span className="size-3.5 rounded-full border border-muted-foreground/30 shrink-0" />
)}
<span className={cn(
'text-xs font-medium',
isPending ? 'text-muted-foreground' : 'text-foreground',
)}>
{meta.label}
</span>
</div>
{/* 审核进行中:流式打字机文本 */}
{isStreaming && item && <StreamingText item={item} />}
{/* 审核完成:得分 + 问题列表 */}
{isDone && item && (
<>
<ScoreBar score={item.score} />
{item.issues.length > 0 ? (
<div className="space-y-1 mt-1">
{item.issues.slice(0, 3).map((iss, i) => (
<IssueRow key={i} issue={iss} />
))}
{item.issues.length > 3 && (
<p className="text-[11px] text-muted-foreground pl-2">
+{item.issues.length - 3} 条意见…
</p>
)}
</div>
) : (
<p className="text-[11px] text-green-600 dark:text-green-400 pl-5">
无问题
</p>
)}
</>
)}
</div>
);
})}
</div>
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,79 @@
import { CheckCircle2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { RevisionSummary } from '../../types/ai-writing';
interface RevisionSummaryCardProps {
summary: RevisionSummary;
}
const CATEGORY_LABEL: Record<string, string> = {
fact: '事实',
logic: '逻辑',
language: '语言',
};
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
const wordDiff = newWordCount - oldWordCount;
return (
<div className="rounded-md border border-green-200 dark:border-green-900/50 bg-green-50/50 dark:bg-green-950/20 overflow-hidden text-xs">
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-green-200/60 dark:border-green-900/60 bg-green-100/40 dark:bg-green-900/30">
<span className="font-medium text-green-700 dark:text-green-400">修改完成</span>
<span className="text-muted-foreground tabular-nums">
{newWordCount.toLocaleString()} 字
{wordDiff !== 0 && (
<span className={cn('ml-1', wordDiff > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
({wordDiff > 0 ? '+' : ''}{wordDiff})
</span>
)}
</span>
</div>
<div className="px-3 py-2.5 space-y-2">
{/* 已修改 */}
{revisedIssues.length > 0 && (
<div className="space-y-1">
<p className="text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
<CheckCircle2 className="size-3.5" />
已修改 {revisedIssues.length} 条意见
</p>
{revisedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground leading-snug">
<span className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
iss.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
iss.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
)}>
{CATEGORY_LABEL[iss.category] ?? iss.category}
</span>
<span>{iss.issue}</span>
</div>
))}
</div>
)}
{/* 已跳过 */}
{skippedIssues.length > 0 && (
<div className="space-y-1">
<p className="text-muted-foreground font-medium flex items-center gap-1">
<XCircle className="size-3.5" />
跳过 {skippedIssues.length} 条意见
</p>
{skippedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground/60 leading-snug line-through">
<span>{iss.issue}</span>
</div>
))}
</div>
)}
{revisedIssues.length === 0 && skippedIssues.length === 0 && (
<p className="text-muted-foreground text-center py-1">已完成修改</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { cn } from '@/lib/utils';
interface ScoreBarProps {
label: string;
score: number;
className?: string;
}
export function ScoreBar({ label, score, className }: ScoreBarProps) {
const clipped = Math.min(100, Math.max(0, score));
const color =
clipped >= 80
? 'bg-green-500'
: clipped >= 60
? 'bg-yellow-500'
: 'bg-red-500';
const verdict =
clipped >= 80 ? '优秀' : clipped >= 60 ? '需改进' : '不合格';
return (
<div className={cn('space-y-1', className)}>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{label}</span>
<span className="font-medium tabular-nums">
{clipped}
<span className="text-muted-foreground/60 ml-1">({verdict})</span>
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${clipped}%` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { CheckCircle2, Circle, Loader2, PauseCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ProgressEvent } from '../../types/ai-writing';
type StepStatus = 'done' | 'running' | 'paused' | 'waiting';
interface TimelineStepProps {
event: ProgressEvent;
isLast?: boolean;
children?: React.ReactNode;
}
function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
if (event.type === 'await_user') return 'paused';
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
if (event.type === 'step_start') return isLast ? 'running' : 'done';
return 'done';
}
const AGENT_COLOR: Record<string, string> = {
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
编辑: 'text-orange-500',
系统: 'text-muted-foreground',
};
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
const status = statusOf(event, isLast ?? true);
return (
<div className="flex gap-3">
{/* 竖线 + 图标 */}
<div className="flex flex-col items-center">
<StatusIcon status={status} />
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* 内容 */}
<div className={cn('pb-4 flex-1 min-w-0', isLast && 'pb-0')}>
<div className="flex items-center gap-1.5 mb-0.5">
{event.agentName && (
<span
className={cn(
'text-xs font-semibold',
AGENT_COLOR[event.agentName] ?? 'text-muted-foreground',
)}
>
{event.agentName}
</span>
)}
<span className="text-xs text-muted-foreground">{event.message}</span>
</div>
{children}
</div>
</div>
);
}
function StatusIcon({ status }: { status: StepStatus }) {
switch (status) {
case 'done':
return <CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />;
case 'running':
return <Loader2 className="size-4 text-blue-500 shrink-0 mt-0.5 animate-spin" />;
case 'paused':
return <PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />;
default:
return <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />;
}
}
@@ -0,0 +1,232 @@
import { cn } from '@/lib/utils';
import type { WritingStatus } from '../../types/ai-writing';
interface WritingSkeletonCardProps {
status: WritingStatus;
/** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */
researchProgress?: string;
}
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
function SkeletonLine({ className }: { className?: string }) {
return (
<div className={cn('h-3 rounded-md bg-muted animate-pulse', className)} />
);
}
function ThinkingDots({ color = 'bg-blue-400' }: { color?: string }) {
return (
<span className="inline-flex gap-1 items-center">
{[0, 1, 2].map((i) => (
<span
key={i}
className={cn('size-1.5 rounded-full animate-bounce', color)}
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
);
}
// ── 素材收集骨架屏 ────────────────────────────────────────────────────────────
function MaterialsSkeleton({ researchProgress }: { researchProgress?: string }) {
return (
<div className="space-y-3 p-4">
{/* 标题行:展示素材收集专家的实时进度 */}
<div className="flex items-center gap-2 text-xs text-blue-500 font-medium">
<ThinkingDots color="bg-blue-400" />
<span>{researchProgress || '素材收集专家搜索中…'}</span>
</div>
{/* 关键词标签占位 */}
<div className="flex gap-1.5 flex-wrap">
{[60, 80, 50].map((w, i) => (
<div
key={i}
className="h-5 rounded-full bg-blue-100/60 dark:bg-blue-900/30 animate-pulse"
style={{ width: `${w}px` }}
/>
))}
</div>
{/* 素材卡片占位 */}
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border bg-muted/30 p-3 space-y-2">
{/* 标题 */}
<div className="flex items-start gap-2">
<SkeletonLine className={cn('flex-1 h-3.5', i % 3 === 0 ? 'w-4/5' : i % 3 === 1 ? 'w-3/4' : 'w-2/3')} />
</div>
{/* 来源 + 时间 + 相关度 */}
<div className="flex items-center gap-2">
<SkeletonLine className="w-20 h-2" />
<SkeletonLine className="w-16 h-2" />
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-blue-200/60 dark:bg-blue-700/40 animate-pulse"
style={{ width: `${55 + i * 8}%` }}
/>
</div>
</div>
{/* 摘要行 */}
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-5/6' : 'w-4/5')} />
</div>
))}
</div>
);
}
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
function OutlineSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家规划大纲中…</span>
</div>
{/* 文章标题占位 */}
<SkeletonLine className="w-3/5 h-4 mx-auto" />
{/* 章节列表 */}
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-2 pl-1">
<div className="mt-1 size-2 shrink-0 rounded-sm bg-purple-200/70 dark:bg-purple-800/40 animate-pulse" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className={cn('h-3', i % 2 === 0 ? 'w-2/5' : 'w-1/2')} />
<SkeletonLine className="w-3/5 h-2" />
{i % 2 === 0 && <SkeletonLine className="w-2/5 h-2" />}
</div>
</div>
))}
</div>
);
}
// ── 正文写作骨架屏 ────────────────────────────────────────────────────────────
function DraftSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家写作中…</span>
</div>
{/* 标题占位 */}
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{/* 章节内容 */}
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-11/12')} />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-3/4 h-2" />
</div>
))}
</div>
);
}
// ── 编辑审核骨架屏 ────────────────────────────────────────────────────────────
function ReviewSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-orange-500 font-medium">
<ThinkingDots color="bg-orange-400" />
<span>编辑审核中…</span>
</div>
{/* 综合评分占位 */}
<div className="flex items-center justify-between">
<SkeletonLine className="w-24 h-3" />
<SkeletonLine className="w-12 h-4" />
</div>
{/* 分项评分条 */}
{['事实准确度', '逻辑结构', '语言质量'].map((label, i) => (
<div key={label} className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">{label}</span>
<SkeletonLine className="w-8 h-3" />
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-orange-200/60 dark:bg-orange-700/40 animate-pulse"
style={{ width: `${55 + i * 10}%` }}
/>
</div>
</div>
))}
{/* 意见条目占位 */}
<div className="space-y-2 pt-1 border-t">
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-5/6 h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-4/5 h-2" />
</div>
</div>
);
}
// ── 按意见修改骨架屏 ──────────────────────────────────────────────────────────
function RevisingSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家按意见修改中…</span>
</div>
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-full')} />
</div>
))}
</div>
);
}
// ── 状态映射 ──────────────────────────────────────────────────────────────────
// researching / awaiting_material_confirm 由 WritingSkeletonCard 单独处理(需透传进度文案)
const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
// 实际运行状态
writing_outline: OutlineSkeleton,
writing_draft: DraftSkeleton,
reviewing: ReviewSkeleton,
revising: RevisingSkeleton,
// 过渡等待状态(用户提交后、后端事件到达前的空白期)
awaiting_outline_confirm: OutlineSkeleton, // 过渡到写作
awaiting_draft_confirm: DraftSkeleton, // 提交修改意见或编辑审核后
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
};
export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) {
// 素材收集阶段单独渲染,以便透传实时进度文案
if (status === 'researching' || status === 'awaiting_material_confirm') {
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<MaterialsSkeleton researchProgress={researchProgress} />
</div>
);
}
const Skeleton = SKELETON_MAP[status];
if (!Skeleton) return null;
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<Skeleton />
</div>
);
}
@@ -3,10 +3,9 @@ import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import type { Extension } from "@codemirror/state";
import type { ViewUpdate } from "@codemirror/view";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import CodeMirror, { type ViewUpdate } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useCallback, useMemo } from "react";
@@ -101,27 +101,35 @@ export function WritingRewritePreview({
// ── Position ────────────────────────────────────────────────────────────
const panelWidth = 480;
const containerRef = useRef<HTMLDivElement>(null);
const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null);
const [computedPos, setComputedPos] = useState<{
left: number;
top?: number;
bottom?: number;
maxHeight: number;
} | null>(null);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const h = el.getBoundingClientRect().height;
const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - panelWidth - 8);
const spaceBelow = window.innerHeight - pos.y - 8;
const spaceAbove = pos.y - 8;
let safeY: number;
if (spaceBelow >= h) {
safeY = pos.y + 6;
} else if (spaceAbove >= h) {
safeY = pos.y - h - 6;
} else if (spaceBelow >= spaceAbove) {
safeY = Math.max(8, window.innerHeight - h - 8);
const MARGIN = 8;
const GAP = 6;
const MIN_HEIGHT = 160;
const safeX = Math.min(Math.max(pos.x, MARGIN), window.innerWidth - panelWidth - MARGIN);
const spaceBelow = window.innerHeight - pos.y - MARGIN;
const spaceAbove = pos.y - MARGIN;
if (spaceBelow >= spaceAbove) {
// More space below — anchor top at selection bottom, grow downward
setComputedPos({
left: safeX,
top: pos.y + GAP,
maxHeight: Math.max(MIN_HEIGHT, spaceBelow - GAP),
});
} else {
safeY = 8;
// More space above — anchor bottom at selection top, grow upward
setComputedPos({
left: safeX,
bottom: window.innerHeight - pos.y + GAP,
maxHeight: Math.max(MIN_HEIGHT, spaceAbove - GAP),
});
}
setComputedPos({ left: safeX, top: safeY });
}, [pos.x, pos.y]);
const actionLabel = tone
@@ -134,16 +142,18 @@ export function WritingRewritePreview({
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
ref={containerRef}
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
top: computedPos?.top ?? (pos.y + 6),
...(computedPos?.bottom !== undefined
? { bottom: computedPos.bottom }
: { top: computedPos?.top ?? (pos.y + 6) }),
maxHeight: computedPos?.maxHeight,
width: panelWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="rounded-xl border bg-popover shadow-xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
className="flex flex-col rounded-xl border bg-popover shadow-xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/40">
@@ -179,7 +189,7 @@ export function WritingRewritePreview({
</div>
{/* Body */}
<div className="p-3 space-y-2 max-h-96 overflow-y-auto">
<div className="p-3 space-y-2 overflow-y-auto flex-1 min-h-0">
{/* Thinking — collapsible with animation */}
{thinking && (
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import {
@@ -6,18 +6,23 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
type LeftMode = "chat" | "writing";
export function CanvasResizableLayout() {
const { isChatCollapsed } = useCanvasUIContext();
const layoutRef = useRef<GroupImperativeHandle>(null);
const [leftMode, setLeftMode] = useState<LeftMode>("chat");
useEffect(() => {
layoutRef.current?.setLayout(
@@ -39,9 +44,29 @@ export function CanvasResizableLayout() {
minSize={0}
collapsible
collapsedSize={0}
className="overflow-hidden"
className="overflow-hidden flex flex-col"
>
<CanvasChatPanel />
{/* 模式切换标签栏 */}
<div className="flex shrink-0 border-b bg-muted/30">
{(["chat", "writing"] as LeftMode[]).map((mode) => (
<button
key={mode}
onClick={() => setLeftMode(mode)}
className={cn(
"flex-1 py-1.5 text-xs font-medium transition-colors",
leftMode === mode
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{mode === "chat" ? "对话" : "AI 写作"}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -0,0 +1,413 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
AIWritingRequest,
AIWritingState,
AIMaterial,
AIOutline,
CompletedIntervention,
ProgressEvent,
ReviewResult,
StreamingDraftSection,
StreamingReviewItem,
UserInterventionPayload,
WritingStatus,
} from '../types/ai-writing';
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
isPaused: false,
progressEvents: [],
materials: [],
outline: null,
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
error: null,
streamingDraftTitle: '',
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
researchProgress: '',
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
};
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const [state, setState] = useState<AIWritingState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const unsubRef = useRef<(() => void) | null>(null);
const handleEvent = useCallback(
(event: ProgressEvent) => {
setState((prev) => {
const base: AIWritingState = {
...prev,
progressEvents: [...prev.progressEvents, event],
};
switch (event.type) {
case 'step_start':
// 兜底:编辑节点完成时若没有流式事件,也切到审核态
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
return { ...base, status: 'reviewing' };
}
return base;
case 'step_done':
return base;
case 'research_progress':
// 素材收集专家实时进度:仅更新最新提示文案
return { ...base, researchProgress: event.message ?? '' };
case 'resumed':
return { ...base, currentPause: null };
case 'materials_ready':
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹);进度文案已完成可清空
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
};
case 'outline_chunk': {
const chunkText = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
status: 'writing_outline',
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + chunkText,
outlineThinkingText: isThinking ? prev.outlineThinkingText + chunkText : prev.outlineThinkingText,
};
}
case 'outline_ready':
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本
return {
...base,
outline: (event.outline ?? null) as AIOutline | null,
outlineStreamingText: '',
outlineThinkingText: '',
};
case 'draft_chunk': {
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
const existing = sections.find((s) => s.sectionIndex === idx);
if (existing) {
if (isThinking) existing.thinkingText += text;
else existing.text += text;
existing.isDone = done;
} else {
sections.push({
sectionIndex: idx,
sectionTitle: title,
text: isThinking ? '' : text,
thinkingText: isThinking ? text : '',
isDone: done,
});
}
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
};
}
case 'review_chunk': {
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
// 首个 chunk 且非审核态 → 新一轮审核,清空上一轮残留
const isNewCycle = prev.status !== 'reviewing';
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
let item = items.find((it) => it.category === category);
if (!item) {
item = {
category, isDone: false,
streamingText: '', thinkingText: '',
score: 0, issues: [],
};
items.push(item);
}
if (isThinking) item.thinkingText += text;
else item.streamingText += text;
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'review_item_done': {
const category = event.reviewItemCategory ?? 'fact';
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
const item = items.find((it) => it.category === category);
if (item) {
item.isDone = true;
item.score = event.reviewItemScore ?? 80;
item.issues = event.reviewItemIssues ?? [];
} else {
// 兜底:没收到任何 chunk 直接来 done
items.push({
category, isDone: true,
streamingText: '', thinkingText: '',
score: event.reviewItemScore ?? 80,
issues: event.reviewItemIssues ?? [],
});
}
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'draft_ready': {
const newMarkdown = event.draftMarkdown ?? '';
const newWordCount = countWords(newMarkdown);
const summary = prev.pendingRevisionContext
? {
revisedIssues: prev.pendingRevisionContext.submittedIssues,
skippedIssues: prev.pendingRevisionContext.skippedIssues,
oldWordCount: prev.pendingRevisionContext.oldWordCount,
newWordCount,
}
: null;
return {
...base,
currentDraftMarkdown: newMarkdown,
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
streamingDraftSections: [],
streamingDraftTitle: '',
revisionSummary: summary,
pendingRevisionContext: null,
};
}
case 'review_ready':
return {
...base,
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
};
case 'await_user': {
const pp = event.pausePoint;
// Skip duplicate on SSE reconnect: backend re-injects the saved await_user event
// when queue is empty. If we already have this pause set, nothing needs to change.
if (prev.currentPause?.pausePoint === pp) return prev;
const pauseStatus = pp
? (`awaiting_${pp}` as AIWritingState['status'])
: prev.status;
// 若 await_user 里携带了素材/大纲/审核数据,一并更新(兜底 materials_ready 丢失的情况)
const extra: Partial<AIWritingState> = {};
if (pp === 'material_confirm' && event.materials?.length) {
extra.materials = event.materials as AIMaterial[];
}
if (pp === 'outline_confirm' && event.outline) {
extra.outline = event.outline as AIOutline;
}
return { ...base, ...extra, status: pauseStatus, currentPause: event };
}
case 'done':
return { ...base, status: 'done', currentPause: null };
case 'error':
return { ...base, status: 'error', error: event.message ?? '未知错误' };
default:
return base;
}
});
// 草稿实时同步到右侧 Artifact 面板
if (event.type === 'draft_ready' && event.draftMarkdown) {
setArtifact({
currentIndex: 0,
contents: [
{
index: 0,
type: 'text',
title: event.draftTitle ?? '草稿',
fullMarkdown: event.draftMarkdown,
},
],
});
}
},
[setArtifact],
);
const startWriting = useCallback(
async (request: AIWritingRequest) => {
if (
state.status !== 'idle' &&
state.status !== 'done' &&
state.status !== 'error'
)
return;
abortRef.current?.abort();
abortRef.current = new AbortController();
setState({ ...INITIAL_STATE, status: 'researching' });
try {
const sessionId = await startWritingSession(request);
setState((prev) => ({ ...prev, sessionId }));
unsubRef.current = subscribeToProgress(
sessionId,
handleEvent,
abortRef.current.signal,
);
} catch (err) {
setState((prev) => ({
...prev,
status: 'error',
error: String(err),
}));
}
},
[state.status, handleEvent],
);
const submitIntervention = useCallback(
async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
// 若处于暂停状态,先重连 SSE 再提交,实现"干预即续接"
if (state.isPaused) {
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') {
record.materials = state.materials;
} else if (pausePoint === 'outline_confirm') {
record.outline = state.outline ?? undefined;
} else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
// 提交编辑意见修改时,记录本次选中/跳过的意见用于后续生成摘要
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview =
payload.selectedIssueIndices !== undefined &&
state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const submittedIssues = allIssues.filter((_, i) => selectedSet.has(i));
const skippedIssues = allIssues.filter((_, i) => !selectedSet.has(i));
pendingRevisionContext = {
submittedIssues,
skippedIssues,
oldWordCount: countWords(state.currentDraftMarkdown),
};
}
// Optimistic next-status so the correct skeleton shows immediately after
// the user clicks confirm, without waiting for the first SSE chunk.
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising'; // user_revise
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review
}
try {
await resumeWritingApi(state.sessionId, payload);
setState((prev) => ({
...prev,
currentPause: null,
status: nextStatus,
// Clear stale streaming state so new-cycle detection in chunk handlers works correctly
...(nextStatus === 'researching' ? { researchProgress: '' } : {}),
...(nextStatus === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(nextStatus === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(nextStatus === 'reviewing' ? { streamingReviewItems: [] } : {}),
...(nextStatus === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
completedInterventions: [...prev.completedInterventions, record],
...(pendingRevisionContext ? { pendingRevisionContext } : {}),
}));
} catch (err) {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
);
const pauseWriting = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setState((prev) => ({ ...prev, isPaused: true }));
}, []);
const resumeWriting = useCallback(() => {
if (!state.sessionId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}, [state.sessionId, handleEvent]);
const reset = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setArtifact(null);
setState(INITIAL_STATE);
}, [setArtifact]);
useEffect(() => () => { abortRef.current?.abort(); }, []);
// 在暂停点 / 完成 / 出错时,把完整对话时间线持久化到后端,供历史回显出与实时一致的内容
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
// 仅在状态进入可保存阶段时触发;events/interventions 取闭包最新值,无需进 deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset };
}
@@ -0,0 +1,45 @@
import { useCallback, useState } from 'react';
import {
listAIWritingSessions,
renameAIWritingSession,
deleteAIWritingSession,
} from '../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
export function useAIWritingSessions() {
const [sessions, setSessions] = useState<AIWritingSessionSummary[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listAIWritingSessions();
setSessions(data);
} catch {
// silently ignore — history is non-critical
} finally {
setLoading(false);
}
}, []);
const rename = useCallback(async (id: string, title: string) => {
try {
const updated = await renameAIWritingSession(id, title);
setSessions((prev) => prev.map((s) => (s.id === id ? updated : s)));
} catch {
// ignore
}
}, []);
const remove = useCallback(async (id: string) => {
try {
await deleteAIWritingSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
} catch {
// ignore
}
}, []);
return { sessions, loading, refresh, rename, remove };
}
@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import {
listArticleTypes,
createArticleType,
updateArticleType,
deleteArticleType,
} from '../api/ai-writing-article-types';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
export function useArticleTypes() {
const [articleTypes, setArticleTypes] = useState<ArticleType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listArticleTypes();
setArticleTypes(data);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
const create = useCallback(async (data: CreateArticleTypeRequest): Promise<ArticleType> => {
const created = await createArticleType(data);
setArticleTypes((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order));
return created;
}, []);
const update = useCallback(async (id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> => {
const updated = await updateArticleType(id, data);
setArticleTypes((prev) =>
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
);
return updated;
}, []);
const remove = useCallback(async (id: string): Promise<void> => {
await deleteArticleType(id);
setArticleTypes((prev) => prev.filter((t) => t.id !== id));
}, []);
return { articleTypes, loading, error, refresh, create, update, remove };
}
@@ -122,7 +122,7 @@ export function useWritingRewrite(editor: Editor | null) {
const rewrite = useCallback(
async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
if (!writingSelection || !threadId || !editor) return;
if (!writingSelection || !editor) return;
abortRef.current?.abort();
const abort = new AbortController();
@@ -152,12 +152,12 @@ export function useWritingRewrite(editor: Editor | null) {
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, threadId, modelName, editor, runStream],
[writingSelection, modelName, editor, runStream],
);
const rewriteFollowUp = useCallback(
async (instruction: string, action: RewriteAction, tone?: RewriteTone) => {
if (!writingSelection || !threadId || !editor) return;
if (!writingSelection || !editor) return;
const prevResult = result;
if (!prevResult) return;
@@ -190,7 +190,7 @@ export function useWritingRewrite(editor: Editor | null) {
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, threadId, modelName, editor, result, runStream],
[writingSelection, modelName, editor, result, runStream],
);
const reset = useCallback(() => {
@@ -0,0 +1,74 @@
import { useRef } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { GroupImperativeHandle } from 'react-resizable-panels';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPageHeader } from '../components/ai-writing/AIWritingPageHeader';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
// Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<AIWritingPageHeader />
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
<ResizablePanel
id={WRITING_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:草稿预览面板 */}
<ResizablePanel
id={DRAFT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -0,0 +1,431 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, FileText, Pencil, Plus, Settings2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useArticleTypes } from '../hooks/useArticleTypes';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
// ── 样式常量 ──────────────────────────────────────────────────────────────────
const INPUT_CLASS =
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50 disabled:bg-muted';
const TEXTAREA_CLASS =
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring resize-none';
// ── 表单状态类型 ───────────────────────────────────────────────────────────────
interface FormState {
key: string;
label: string;
description: string;
prompt_hint: string;
default_word_count: string;
sort_order: string;
}
const EMPTY_FORM: FormState = {
key: '',
label: '',
description: '',
prompt_hint: '',
default_word_count: '800',
sort_order: '0',
};
// ── 主页面 ────────────────────────────────────────────────────────────────────
export default function ArticleTypesConfigPage() {
const navigate = useNavigate();
const { articleTypes, loading, error, refresh, create, update, remove } = useArticleTypes();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
refresh();
}, [refresh]);
// ── 对话框操作 ──────────────────────────────────────────────────────────────
function openCreate() {
setEditingId(null);
setForm(EMPTY_FORM);
setFormError(null);
setDialogOpen(true);
}
function openEdit(t: ArticleType) {
setEditingId(t.id);
setForm({
key: t.key,
label: t.label,
description: t.description ?? '',
prompt_hint: t.prompt_hint ?? '',
default_word_count: String(t.default_word_count),
sort_order: String(t.sort_order),
});
setFormError(null);
setDialogOpen(true);
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
async function handleSave() {
const trimmedKey = form.key.trim();
const trimmedLabel = form.label.trim();
const wordCount = parseInt(form.default_word_count, 10);
if (!trimmedKey) { setFormError('标识不能为空'); return; }
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
setSaving(true);
setFormError(null);
try {
if (editingId) {
const req: UpdateArticleTypeRequest = {
label: trimmedLabel,
description: form.description.trim() || null,
prompt_hint: form.prompt_hint.trim() || null,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await update(editingId, req);
} else {
const req: CreateArticleTypeRequest = {
key: trimmedKey,
label: trimmedLabel,
description: form.description.trim() || undefined,
prompt_hint: form.prompt_hint.trim() || undefined,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await create(req);
}
setDialogOpen(false);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
// ── 删除操作 ────────────────────────────────────────────────────────────────
async function handleDelete() {
if (!deleteTargetId) return;
setDeleting(true);
setDeleteError(null);
try {
await remove(deleteTargetId);
setDeleteTargetId(null);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleting(false);
}
}
// ── 渲染 ────────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
{/* 页面头部 */}
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate(-1)}
title="返回"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Settings2 className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold flex-1">文章类型配置</span>
<Button size="sm" className="h-7 px-3 text-xs gap-1.5" onClick={openCreate}>
<Plus className="size-3.5" />
新增类型
</Button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-4">
{loading && (
<p className="text-sm text-muted-foreground text-center py-12">加载中…</p>
)}
{!loading && error && (
<p className="text-sm text-destructive text-center py-12">{error}</p>
)}
{!loading && !error && articleTypes.length === 0 && (
<div className="text-center py-12 space-y-2">
<Settings2 className="h-8 w-8 mx-auto text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无文章类型</p>
<p className="text-xs text-muted-foreground">点击右上角"新增类型"创建第一个文章类型</p>
</div>
)}
{!loading && articleTypes.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{articleTypes.map((t) => (
<ArticleTypeCard
key={t.id}
type={t}
onEdit={() => openEdit(t)}
onDelete={() => { setDeleteError(null); setDeleteTargetId(t.id); }}
/>
))}
</div>
)}
</div>
{/* 新增 / 编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!saving) setDialogOpen(open); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? '编辑文章类型' : '新增文章类型'}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<FormField label="标识 (key)" required>
<input
className={INPUT_CLASS}
placeholder="如 news、analysis(传给 AI 的唯一标识,仅限字母数字下划线)"
value={form.key}
onChange={(e) => setField('key', e.target.value)}
disabled={!!editingId}
/>
{editingId && (
<p className="text-[11px] text-muted-foreground mt-0.5">标识创建后不可修改</p>
)}
</FormField>
<FormField label="名称" required>
<input
className={INPUT_CLASS}
placeholder="如 新闻资讯、深度特稿"
value={form.label}
onChange={(e) => setField('label', e.target.value)}
/>
</FormField>
<FormField label="说明">
<input
className={INPUT_CLASS}
placeholder="简短描述,帮助用户了解该类型的适用场景"
value={form.description}
onChange={(e) => setField('description', e.target.value)}
/>
</FormField>
<FormField label="写作风格提示词">
<textarea
className={TEXTAREA_CLASS}
rows={3}
placeholder="传入 AI 的写作风格说明,如:以新闻报道风格写作,注重 5W1H 要素,语言简洁客观…"
value={form.prompt_hint}
onChange={(e) => setField('prompt_hint', e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="默认字数(字)" required>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
value={form.default_word_count}
onChange={(e) => setField('default_word_count', e.target.value)}
/>
</FormField>
<FormField label="排序权重">
<input
type="number"
min={0}
className={INPUT_CLASS}
placeholder="0"
value={form.sort_order}
onChange={(e) => setField('sort_order', e.target.value)}
/>
</FormField>
</div>
{formError && (
<p className="text-xs text-destructive">{formError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
取消
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog
open={deleteTargetId !== null}
onOpenChange={(open) => { if (!deleting && !open) setDeleteTargetId(null); }}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-2">
<p className="text-sm text-muted-foreground">
删除后不可恢复。已使用该类型创建的历史写作不受影响,但新写作将无法选择此类型。
</p>
{deleteError && (
<p className="text-xs text-destructive">{deleteError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTargetId(null)}
disabled={deleting}
>
取消
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? '删除中…' : '确认删除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── 文章类型卡片 ──────────────────────────────────────────────────────────────
function ArticleTypeCard({
type,
onEdit,
onDelete,
}: {
type: ArticleType;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2.5 hover:border-primary/30 transition-colors">
{/* 标题行 */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">{type.label}</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground border">
{type.key}
</span>
</div>
{type.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onEdit}
title="编辑"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive/70 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 写作提示词预览 */}
{type.prompt_hint && (
<div className="rounded-md bg-muted/50 px-2.5 py-2">
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{type.prompt_hint}
</p>
</div>
)}
{/* 底部元信息 */}
<div className="flex items-center gap-3 pt-0.5 border-t">
<span className="text-[11px] text-muted-foreground flex items-center gap-1 pt-2">
<FileText className="h-3 w-3" />
默认 {type.default_word_count} 字
</span>
<span className="text-[11px] text-muted-foreground pt-2">
排序 {type.sort_order}
</span>
</div>
</div>
);
}
// ── 表单字段包装 ──────────────────────────────────────────────────────────────
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
</div>
);
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
import { FileText, Code2, Sparkles, Loader2, PenLine } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createCanvasThread } from "../api/threads";
@@ -86,6 +86,23 @@ export default function CanvasHomePage() {
))}
</div>
{/* AI 智能写作入口 */}
<button
onClick={() => navigate('ai-writing')}
className="group w-full flex items-center gap-4 rounded-xl border border-orange-200 dark:border-orange-900/50 bg-orange-50/50 dark:bg-orange-950/20 p-5 text-left transition-all hover:border-orange-400/60 hover:shadow-md hover:bg-orange-50 dark:hover:bg-orange-950/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-400"
>
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/40 shrink-0 group-hover:bg-orange-200/80 dark:group-hover:bg-orange-800/40 transition-colors">
<PenLine className="w-5 h-5 text-orange-500 dark:text-orange-400" />
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm text-orange-700 dark:text-orange-300">AI 智能写作</p>
<p className="text-xs text-orange-600/70 dark:text-orange-400/60 leading-relaxed mt-0.5">
多智能体协作——搜集素材、规划大纲、起草正文、编辑审核,全流程自动完成
</p>
</div>
<span className="text-orange-400/60 shrink-0 text-xs">→</span>
</button>
<div className="text-center">
<Button
variant="ghost"
@@ -2,11 +2,15 @@ import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
);
@@ -0,0 +1,217 @@
// ── 素材 ─────────────────────────────────────────────────────────────────────
export interface AIMaterial {
id: string;
title: string;
content: string;
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet';
url?: string;
recUuid?: string;
}
// ── 大纲 ─────────────────────────────────────────────────────────────────────
export interface OutlineSection {
sectionTitle: string;
keyPoints: string[];
materialIds: string[];
}
export interface AIOutline {
title: string;
sections: OutlineSection[];
}
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
export interface ReviewIssue {
category: 'fact' | 'logic' | 'language';
severity: 'major' | 'minor';
location: string;
issue: string;
suggestion: string;
}
export interface ReviewResult {
verdict: 'pass' | 'reject';
factScore: number;
logicScore: number;
languageScore: number;
overallScore: number;
passThreshold: number;
revisionNotes: ReviewIssue[];
}
// ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus =
| 'idle'
| 'researching'
| 'awaiting_material_confirm'
| 'writing_outline'
| 'awaiting_outline_confirm'
| 'writing_draft'
| 'awaiting_draft_confirm'
| 'reviewing'
| 'awaiting_review_confirm'
| 'revising'
| 'done'
| 'error';
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
export type ProgressEventType =
| 'connected'
| 'step_start'
| 'step_done'
| 'research_progress'
| 'materials_ready'
| 'outline_chunk'
| 'outline_ready'
| 'draft_chunk'
| 'draft_ready'
| 'review_chunk'
| 'review_item_done'
| 'review_ready'
| 'await_user'
| 'resumed'
| 'done'
| 'error';
export interface ProgressEvent {
type: ProgressEventType;
sessionId: string;
agentName?: string;
message: string;
// materials_ready
materials?: AIMaterial[];
keywords?: string[];
materialSummary?: string;
// outline_ready
outline?: AIOutline;
// draft_ready / draft_chunk
draftMarkdown?: string;
draftTitle?: string;
revisionCount?: number;
// review_item_done (streaming per-category result)
reviewItemCategory?: 'fact' | 'logic' | 'language';
reviewItemScore?: number;
reviewItemIssues?: ReviewIssue[];
// review_ready
reviewResult?: ReviewResult;
// await_user
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
pauseContext?: Record<string, unknown>;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
export interface UserInterventionPayload {
action: string;
approvedMaterialIds?: string[];
extraKeywords?: string[];
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
overrideVerdict?: boolean;
// 用户勾选的审核意见索引(基于 reviewResult.revisionNotes),未传则默认全选
selectedIssueIndices?: number[];
}
// ── 写作请求 ──────────────────────────────────────────────────────────────────
export interface AIWritingRequest {
userIntent: string;
articleType: string;
targetAudience: string;
wordCountTarget: number;
modelName?: string;
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
export interface StreamingReviewItem {
category: 'fact' | 'logic' | 'language';
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
isDone: boolean;
// 流式过程文本(正文 / 思考分开)
streamingText: string;
thinkingText: string;
// 完成后的结构化结果
score: number;
issues: ReviewIssue[];
}
// ── 修改摘要 ──────────────────────────────────────────────────────────────────
export interface RevisionSummary {
revisedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
newWordCount: number;
}
// ── 流式草稿章节 ──────────────────────────────────────────────────────────────
export interface StreamingDraftSection {
sectionIndex: number;
sectionTitle: string;
text: string; // 正文内容(非思考)
thinkingText: string; // 思考内容(<think>...</think> 内)
isDone: boolean;
}
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
export interface CompletedIntervention {
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
action: string;
// material_confirm
materials?: AIMaterial[];
// outline_confirm
outline?: AIOutline;
// draft_confirm
draftTitle?: string;
draftWordCount?: number;
reviewResult?: ReviewResult;
// review_confirm
}
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
isPaused: boolean;
progressEvents: ProgressEvent[];
materials: AIMaterial[];
outline: AIOutline | null;
currentDraftMarkdown: string;
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
currentPause: ProgressEvent | null;
completedInterventions: CompletedIntervention[];
error: string | null;
// 流式草稿
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[];
// 修改摘要
revisionSummary: RevisionSummary | null;
// 待修改上下文(用于生成摘要)
pendingRevisionContext: {
submittedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
} | null;
}
@@ -0,0 +1,12 @@
/**
* 中文语境下的「字数」统计,与后端 `nodes/_utils.py:count_words` 保持同一口径:
* 汉字逐字计 + 中文标点逐个计 + 连续数字/英文各算 1 字。
* 不计入空白、markdown 语法符号(# * - 等)与 ASCII 标点。
*/
export function countWords(text: string): number {
if (!text) return 0;
const cjk = (text.match(/[一-鿿]/g) ?? []).length;
const cjkPunct = (text.match(/[,。、;:?!“”‘’()《》【】「」『』〈〉—…·~]/g) ?? []).length;
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
return cjk + cjkPunct + alnumRuns;
}