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

No files matched your search

@@ -0,0 +1,195 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type {
AIWritingRequest,
ProgressEvent,
ProgressEventType,
UserInterventionPayload,
} from '../types/ai-writing';
const BASE = () => `${getBackendBaseURL()}/api/ai-writing`;
/** 启动写作流程,返回 session_id */
export async function startWritingSession(req: AIWritingRequest): Promise<string> {
const resp = await apiFetch(`${BASE()}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_intent: req.userIntent,
article_type: req.articleType,
target_audience: req.targetAudience,
word_count_target: req.wordCountTarget,
model_name: req.modelName ?? '',
}),
});
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
const data = await resp.json();
return data.session_id;
}
const SSE_EVENT_TYPES: ProgressEventType[] = [
'connected', 'step_start', 'step_done', 'materials_ready', 'outline_ready',
'draft_chunk', 'draft_ready', 'review_ready', 'await_user', 'resumed', 'done', 'error',
];
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
function normalizeMaterial(m: Record<string, unknown>): Record<string, unknown> {
return {
...m,
relevanceScore: m.relevance_score ?? m.relevanceScore ?? 0,
publishedAt: m.published_at ?? m.publishedAt ?? '',
sourceType: m.source_type ?? m.sourceType ?? 'internet',
recUuid: m.recUuid ?? m.rec_uuid ?? '',
};
}
function normalizeOutlineSection(s: Record<string, unknown>): Record<string, unknown> {
return {
...s,
sectionTitle: s.section_title ?? s.sectionTitle ?? '',
keyPoints: s.key_points ?? s.keyPoints ?? [],
materialIds: s.material_ids ?? s.materialIds ?? [],
};
}
function normalizeOutline(o: unknown): unknown {
if (!o || typeof o !== 'object') return o;
const obj = o as Record<string, unknown>;
const sections = Array.isArray(obj.sections)
? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record<string, unknown>))
: [];
return { ...obj, sections };
}
function normalizeReviewIssue(i: Record<string, unknown>): Record<string, unknown> {
return { ...i };
}
function normalizeReviewResult(r: unknown): unknown {
if (!r || typeof r !== 'object') return r;
const obj = r as Record<string, unknown>;
return {
...obj,
factScore: obj.fact_score ?? obj.factScore ?? 0,
logicScore: obj.logic_score ?? obj.logicScore ?? 0,
languageScore: obj.language_score ?? obj.languageScore ?? 0,
overallScore: obj.overall_score ?? obj.overallScore ?? 0,
passThreshold: obj.pass_threshold ?? obj.passThreshold ?? 75,
revisionNotes: Array.isArray(obj.revision_notes)
? obj.revision_notes.map((x: unknown) => normalizeReviewIssue(x as Record<string, unknown>))
: Array.isArray(obj.revisionNotes)
? obj.revisionNotes
: [],
};
}
function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent> {
const out: Record<string, unknown> = { ...parsed };
// 通用字段
if ('agent_name' in out) { out.agentName = out.agent_name; }
if ('pause_point' in out) { out.pausePoint = out.pause_point; }
// materials 数组(materials_ready 和 await_user 里都可能有)
if (Array.isArray(out.materials)) {
out.materials = out.materials.map((m: unknown) =>
normalizeMaterial(m as Record<string, unknown>)
);
}
// outline
if (out.outline) out.outline = normalizeOutline(out.outline);
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
if ('revision_count' in out) out.revisionCount = out.revision_count;
// review_ready
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
return out as Partial<ProgressEvent>;
}
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
signal?: AbortSignal,
): () => void {
let stopped = false;
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
.then(async (response) => {
if (!response.ok || !response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentType = '';
let currentData = '';
while (!stopped) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
currentData = line.slice(6).trim();
} else if (line === '' && currentType && currentData) {
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
try {
const parsed = JSON.parse(currentData);
const normalized = normalizeEvent(parsed);
onEvent({
type: currentType as ProgressEventType,
sessionId,
message: '',
...normalized,
} as ProgressEvent);
} catch {
// malformed JSON — skip
}
}
currentType = '';
currentData = '';
}
}
}
reader.releaseLock();
})
.catch(() => {});
return () => { stopped = true; };
}
/** 提交用户干预决策,恢复暂停的流程 */
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: payload.action,
approved_material_ids: payload.approvedMaterialIds ?? [],
extra_keywords: payload.extraKeywords ?? [],
edited_outline: payload.editedOutline ?? null,
outline_feedback: payload.outlineFeedback ?? '',
user_revision_notes: payload.userRevisionNotes ?? '',
override_verdict: payload.overrideVerdict ?? false,
}),
});
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
}
@@ -0,0 +1,171 @@
import { useEffect, useState } from 'react';
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { AIWritingRequest } from '../../types/ai-writing';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
}
interface ModelOption {
name: string;
displayName: string;
}
const ARTICLE_TYPES: { value: AIWritingRequest['articleType']; label: string }[] = [
{ value: 'analysis', label: '分析报道' },
{ value: 'news', label: '新闻资讯' },
{ value: 'feature', label: '深度特稿' },
{ value: 'report', label: '调研报告' },
];
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] =
useState<AIWritingRequest['articleType']>('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCount, setWordCount] = useState(800);
const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]);
useEffect(() => {
apiFetch(`${getBackendBaseURL()}/api/models`)
.then((r) => r.json())
.then((data) => {
const list: ModelOption[] = (data.models ?? []).map(
(m: { name: string; display_name?: string }) => ({
name: m.name,
displayName: m.display_name || m.name,
}),
);
setModels(list);
if (list.length > 0 && !modelName) {
setModelName(list[0].name);
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
onSubmit({
userIntent: intent.trim(),
articleType,
targetAudience: audience.trim() || '普通读者',
wordCountTarget: wordCount,
modelName: modelName || undefined,
});
}
return (
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
<Textarea
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={4}
className="resize-none text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
<Select
value={articleType}
onValueChange={(v) => setArticleType(v as AIWritingRequest['articleType'])}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ARTICLE_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标字数</label>
<Select
value={String(wordCount)}
onValueChange={(v) => setWordCount(Number(v))}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[500, 800, 1200, 2000, 3000].map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{n} 字
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
{models.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
<Select
value={modelName}
onValueChange={setModelName}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="选择模型…" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
<PenLine className="size-3.5" />
开始 AI 写作
</Button>
</form>
);
}
@@ -0,0 +1,107 @@
import { RotateCcw, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useAIWriting } from '../../hooks/useAIWriting';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
const STATUS_LABEL: Record<string, string> = {
idle: '就绪',
researching: '素材收集专家搜集素材中…',
awaiting_material_confirm: '等待确认素材',
writing_outline: '作家规划大纲中…',
awaiting_outline_confirm: '等待确认大纲',
writing_draft: '作家写作中…',
awaiting_draft_confirm: '等待确认草稿',
reviewing: '编辑审核中…',
awaiting_review_confirm: '等待处理编辑意见',
revising: '按意见修改中…',
done: '写作完成 ✓',
error: '出错了',
};
export function AIWritingPanel() {
const { state, startWriting, submitIntervention, stopWriting, reset } = useAIWriting();
const isIdle = state.status === 'idle';
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold">AI 智能写作</span>
<div className="flex items-center gap-1.5">
{isRunning && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={stopWriting}>
<Square className="size-3" />
停止
</Button>
)}
{(isDone || isError || isRunning) && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
<RotateCcw className="size-3" />
重置
</Button>
)}
</div>
</div>
{/* 主体:可滚动 */}
<div className="flex-1 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm onSubmit={startWriting} />
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
<AIWritingTimeline
events={state.progressEvents}
interventionSlot={
state.currentPause ? (
<InterventionCard
pauseEvent={state.currentPause}
materials={state.materials}
outline={state.outline}
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
revisionCount={state.revisionCount}
onSubmit={submitIntervention}
disabled={false}
/>
) : undefined
}
/>
)}
{/* 出错提示 */}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
</div>
)}
</div>
{/* 状态栏 */}
{!isIdle && (
<div
className={cn(
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{STATUS_LABEL[state.status] ?? state.status}
</div>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import type { ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
interface AIWritingTimelineProps {
events: ProgressEvent[];
/**
* 各干预点的专属内容插槽。
* 在阶段四/五/六填充素材卡、大纲卡、审核卡等组件。
*/
interventionSlot?: React.ReactNode;
}
const DISPLAYED_TYPES = new Set([
'step_start',
'step_done',
'materials_ready',
'outline_ready',
'draft_ready',
'review_ready',
'await_user',
'resumed',
'done',
'error',
]);
export function AIWritingTimeline({ events, interventionSlot }: AIWritingTimelineProps) {
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
if (visible.length === 0) return null;
return (
<div className="px-4 py-3 space-y-0">
{visible.map((event, idx) => {
const isLast = idx === visible.length - 1;
// 在最后一个 await_user 事件后插入干预插槽
const showSlot = isLast && event.type === 'await_user' && interventionSlot;
return (
<TimelineStep key={idx} event={event} isLast={isLast && !showSlot}>
{showSlot ? interventionSlot : null}
</TimelineStep>
);
})}
</div>
);
}
@@ -0,0 +1,398 @@
import { useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
AIMaterial, AIOutline, ReviewResult,
ProgressEvent, UserInterventionPayload,
} from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
interface InterventionCardProps {
pauseEvent: ProgressEvent;
materials: AIMaterial[];
outline: AIOutline | null;
reviewResult: ReviewResult | null;
draftTitle: string;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
}
if (pausePoint === 'outline_confirm') {
return (
<OutlineConfirmCard
outline={outline ?? pauseEvent.outline ?? null}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'draft_confirm') {
return (
<DraftConfirmCard
draftTitle={draftTitle}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmCard
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
revisionCount={revisionCount}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
return (
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
等待用户确认({pausePoint})…
</div>
);
}
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
interface MaterialConfirmCardProps {
materials: AIMaterial[];
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
setSubmitting(false);
}
const hasExtra = extraKeywords.length > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
</p>
</div>
</div>
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}} />
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
/>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
</div>
);
}
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
interface OutlineConfirmCardProps {
outline: AIOutline | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [showReplan, setShowReplan] = useState(false);
const [feedback, setFeedback] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({
action: 'confirm',
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
});
setSubmitting(false);
}
async function handleReplan() {
setSubmitting(true);
await onSubmit({ action: 're_outline', outlineFeedback: feedback.trim() });
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">请确认大纲后继续写作</p>
<p className="text-xs text-muted-foreground mt-0.5">
可直接点击章节标题或要点进行编辑,或要求重新规划大纲。
</p>
</div>
</div>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
{showReplan && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">重新规划意见(可选)</label>
<textarea
rows={2} value={feedback} onChange={(e) => setFeedback(e.target.value)}
placeholder="描述大纲调整方向,例如:增加案例分析章节…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
{showReplan ? (
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReplan} disabled={disabled || submitting}
>
<RefreshCw className="size-3.5" />提交重规划
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => setShowReplan(true)} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新规划…
</Button>
)}
</div>
</div>
);
}
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
interface DraftConfirmCardProps {
draftTitle: string;
revisionCount: number;
reviewResult: ReviewResult | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
const [notes, setNotes] = useState('');
const [showNotes, setShowNotes] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
setSubmitting(true);
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容
</p>
</div>
</div>
{/* 编辑审核分数(若已经过编辑)*/}
{reviewResult && (
<ReviewCard review={reviewResult} revisionCount={revisionCount} />
)}
{/* 修改意见输入 */}
{showNotes && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">您的修改意见</label>
<textarea
rows={2} value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="描述希望修改的内容,例如:第二段数据来源不清晰,结尾过于仓促…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex flex-col gap-1.5 pt-1">
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
<div className="flex gap-1.5">
{showNotes ? (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('user_revise')} disabled={disabled || submitting || !notes.trim()}
>
<PenLine className="size-3.5" />按我的意见修改
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => setShowNotes(true)} disabled={disabled || submitting}
>
<PenLine className="size-3.5" />给出修改意见…
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
</div>
</div>
</div>
);
}
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
interface ReviewConfirmCardProps {
reviewResult: ReviewResult | null;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
const [extraNotes, setExtraNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleAccept() {
setSubmitting(true);
await onSubmit({
action: 'accept_review',
userRevisionNotes: extraNotes.trim() || undefined,
});
setSubmitting(false);
}
async function handleForce() {
setSubmitting(true);
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">编辑认为文章需要修改</p>
<p className="text-xs text-muted-foreground mt-0.5">
请决定下一步操作:采纳编辑意见重写,或忽略编辑意见强制定稿。
</p>
</div>
</div>
{reviewResult
? <ReviewCard review={reviewResult} revisionCount={revisionCount} />
: <p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充修改指令(可选,将覆盖编辑意见)</label>
<textarea
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
placeholder="留空则直接采纳编辑意见;或在此补充您自己的修改方向…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleAccept} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />采纳编辑意见,让作家重写
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { cn } from '@/lib/utils';
import type { ReviewIssue } from '../../types/ai-writing';
interface IssueListProps {
issues: ReviewIssue[];
}
const CATEGORY_LABEL: Record<string, string> = {
fact: '事实',
logic: '逻辑',
language: '语言',
};
const CATEGORY_COLOR: Record<string, string> = {
fact: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
logic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400',
language: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
};
export function IssueList({ issues }: IssueListProps) {
if (issues.length === 0) {
return <p className="text-xs text-muted-foreground text-center py-1">无需修改意见</p>;
}
const major = issues.filter((i) => i.severity === 'major');
const minor = issues.filter((i) => i.severity === 'minor');
return (
<div className="space-y-3">
{major.length > 0 && (
<IssueGroup label="必须修改" labelClass="text-red-500" issues={major} />
)}
{minor.length > 0 && (
<IssueGroup label="建议修改" labelClass="text-yellow-500" issues={minor} />
)}
</div>
);
}
interface IssueGroupProps {
label: string;
labelClass: string;
issues: ReviewIssue[];
}
function IssueGroup({ label, labelClass, issues }: IssueGroupProps) {
return (
<div className="space-y-1.5">
<p className={cn('text-xs font-semibold', labelClass)}>{label}</p>
{issues.map((issue, i) => (
<div key={i} className="rounded border border-border/60 bg-background px-2.5 py-2 space-y-1 text-xs">
<div className="flex items-center gap-1.5">
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', CATEGORY_COLOR[issue.category] ?? 'bg-muted text-muted-foreground')}>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<span className="text-muted-foreground truncate">{issue.location}</span>
</div>
<p className="text-foreground leading-snug">{issue.issue}</p>
<p className="text-muted-foreground leading-snug">
<span className="text-primary mr-1">→</span>
{issue.suggestion}
</p>
</div>
))}
</div>
);
}
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialItemProps {
material: AIMaterial;
checked: boolean;
onToggle: (id: string) => void;
}
function relevanceColor(score: number): string {
if (score >= 0.8) return 'bg-green-500';
if (score >= 0.5) return 'bg-orange-400';
return 'bg-red-400';
}
function relevanceLabel(score: number): string {
if (score >= 0.8) return '高相关';
if (score >= 0.5) return '中相关';
return '低相关';
}
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
const [expanded, setExpanded] = useState(false);
const pct = Math.round(material.relevanceScore * 100);
return (
<div
className={cn(
'rounded-md border text-xs transition-colors',
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
)}
>
{/* 头部行 */}
<div className="flex items-start gap-2 px-2.5 py-2">
{/* Checkbox */}
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(material.id)}
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-input accent-primary cursor-pointer"
/>
{/* 标题 + 展开按钮 */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex flex-1 items-start gap-1.5 text-left"
>
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
{material.title}
</span>
{expanded
? <ChevronDown className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />}
</button>
</div>
{/* 元信息行 */}
<div className="flex items-center gap-2 px-2.5 pb-2 pl-8">
<span className="text-muted-foreground truncate max-w-[120px]">{material.source}</span>
{material.publishedAt && (
<>
<span className="text-muted-foreground/40">·</span>
<span className="text-muted-foreground shrink-0">{material.publishedAt}</span>
</>
)}
{/* 相关度色条 */}
<div className="flex flex-1 items-center gap-1 min-w-0">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
<div
className={cn('h-full rounded-full transition-all', relevanceColor(material.relevanceScore))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn('shrink-0', relevanceColor(material.relevanceScore).replace('bg-', 'text-'))}>
{pct}%
</span>
<span className="text-muted-foreground/60 shrink-0">({relevanceLabel(material.relevanceScore)})</span>
</div>
</div>
{/* 展开内容 */}
{expanded && (
<div className="px-2.5 pb-2.5 pl-8 space-y-1.5 border-t">
<p className="text-muted-foreground leading-relaxed pt-2 line-clamp-6">
{material.content.slice(0, 300)}
{material.content.length > 300 && '…'}
</p>
{material.url && (
<a
href={material.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
查看原文
<ExternalLink className="size-2.5" />
</a>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, FileText } from 'lucide-react';
import type { AIMaterial } from '../../types/ai-writing';
import { MaterialItem } from './MaterialItem';
interface MaterialsCardProps {
materials: AIMaterial[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
}
export function MaterialsCard({ materials, selectedIds, onToggle }: MaterialsCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 折叠头 */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-muted/40 transition-colors text-left"
>
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1">
素材列表
<span className="ml-1 text-muted-foreground font-normal">
(共 {materials.length} 条,已选 {selectedIds.size} 条)
</span>
</span>
{expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />}
</button>
{/* 列表 */}
{expanded && (
<div className="px-2.5 pb-2.5 space-y-1.5 border-t">
{materials.length === 0 ? (
<p className="text-xs text-muted-foreground py-2 text-center">暂无素材</p>
) : (
materials.map((m) => (
<MaterialItem
key={m.id}
material={m}
checked={selectedIds.has(m.id)}
onToggle={onToggle}
/>
))
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialsToolbarProps {
materials: AIMaterial[];
selectedIds: Set<string>;
onSelectAll: () => void;
onDeselectAll: () => void;
extraKeywords: string[];
onAddKeyword: (kw: string) => void;
onRemoveKeyword: (kw: string) => void;
}
export function MaterialsToolbar({
materials,
selectedIds,
onSelectAll,
onDeselectAll,
extraKeywords,
onAddKeyword,
onRemoveKeyword,
}: MaterialsToolbarProps) {
const [kwInput, setKwInput] = useState('');
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
function handleAddKeyword() {
const kw = kwInput.trim();
if (!kw || extraKeywords.includes(kw)) return;
onAddKeyword(kw);
setKwInput('');
}
return (
<div className="space-y-2">
{/* 全选/取消 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={allSelected ? onDeselectAll : onSelectAll}
className="text-xs text-primary hover:underline"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
{/* 补充关键词 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
<div className="flex gap-1.5">
<input
type="text"
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
placeholder="输入关键词后按 Enter 或点击 +"
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handleAddKeyword}
disabled={!kwInput.trim()}
>
<Plus className="size-3" />
</Button>
</div>
{/* 已添加关键词 Tag 列表 */}
{extraKeywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{extraKeywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
onClick={() => onRemoveKeyword(kw)}
className="hover:text-destructive leading-none"
>
×
</button>
</span>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import { useState } from 'react';
import { ListTree, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIOutline, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { OutlineSection } from './OutlineSection';
interface OutlineCardProps {
outline: AIOutline;
onChange?: (updated: AIOutline) => void;
readOnly?: boolean;
}
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
const [collapsed, setCollapsed] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(outline.title);
const editable = !readOnly && !!onChange;
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== outline.title) {
onChange?.({ ...outline, title: trimmed });
} else {
setTitleDraft(outline.title);
}
}
function updateSection(i: number, updated: OutlineSectionType) {
const sections = [...outline.sections];
sections[i] = updated;
onChange?.({ ...outline, sections });
}
function removeSection(i: number) {
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
}
function addSection() {
onChange?.({
...outline,
sections: [...outline.sections, { sectionTitle: '', keyPoints: [], materialIds: [] }],
});
}
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
{/* 头部 */}
<div className="flex items-center gap-2 px-3 py-2 border-b">
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium flex-1">文章大纲</span>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground"
>
{collapsed ? '展开' : '收起'}
</button>
</div>
{!collapsed && (
<div className="px-3 py-2.5 space-y-2">
{/* 文章标题 */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground shrink-0">标题:</span>
{editingTitle && editable ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(outline.title); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-semibold"
/>
) : (
<span
className={editable ? 'flex-1 font-semibold cursor-text hover:text-primary' : 'flex-1 font-semibold'}
onClick={() => editable && setEditingTitle(true)}
>
{outline.title || <span className="text-muted-foreground italic font-normal">(未设置标题)</span>}
</span>
)}
</div>
{/* 章节列表 */}
<div className="space-y-1.5">
{outline.sections.map((section, i) => (
<OutlineSection
key={i}
section={section}
index={i}
onChange={(updated) => updateSection(i, updated)}
onRemove={editable ? () => removeSection(i) : undefined}
readOnly={!editable}
/>
))}
</div>
{/* 添加章节 */}
{editable && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full h-7 text-xs gap-1.5 text-muted-foreground"
onClick={addSection}
>
<Plus className="size-3" />
添加章节
</Button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,126 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, Plus, X, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
interface OutlineSectionProps {
section: OutlineSectionType;
index: number;
onChange: (updated: OutlineSectionType) => void;
onRemove?: () => void;
readOnly?: boolean;
}
export function OutlineSection({ section, index, onChange, onRemove, readOnly }: OutlineSectionProps) {
const [expanded, setExpanded] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== section.sectionTitle) {
onChange({ ...section, sectionTitle: trimmed });
} else {
setTitleDraft(section.sectionTitle);
}
}
function updateKeyPoint(i: number, value: string) {
const next = [...section.keyPoints];
next[i] = value;
onChange({ ...section, keyPoints: next });
}
function removeKeyPoint(i: number) {
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
}
function addKeyPoint() {
onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
}
return (
<div className="rounded border border-border/60 bg-background text-xs">
{/* 章节头 */}
<div className="flex items-center gap-1.5 px-2.5 py-2">
{!readOnly && <GripVertical className="size-3 text-muted-foreground/40 shrink-0 cursor-grab" />}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="shrink-0"
>
{expanded
? <ChevronDown className="size-3.5 text-muted-foreground" />
: <ChevronRight className="size-3.5 text-muted-foreground" />}
</button>
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
{editingTitle && !readOnly ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
/>
) : (
<span
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
onClick={() => !readOnly && setEditingTitle(true)}
>
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
</span>
)}
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
{!readOnly && onRemove && (
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
{/* 要点列表 */}
{expanded && (
<div className="px-8 pb-2.5 space-y-1 border-t">
{section.keyPoints.map((kp, i) => (
<div key={i} className="flex items-center gap-1.5 pt-1.5">
<span className="text-muted-foreground/40 shrink-0">•</span>
{readOnly ? (
<span className="flex-1 text-muted-foreground">{kp}</span>
) : (
<input
value={kp}
onChange={(e) => updateKeyPoint(i, e.target.value)}
placeholder="输入要点…"
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
<X className="size-3" />
</button>
)}
</div>
))}
{!readOnly && (
<button
type="button"
onClick={addKeyPoint}
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
>
<Plus className="size-3" />
添加要点
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import { cn } from '@/lib/utils';
import type { ReviewResult } from '../../types/ai-writing';
import { ScoreBar } from './ScoreBar';
import { IssueList } from './IssueList';
interface ReviewCardProps {
review: ReviewResult;
revisionCount?: number;
}
export function ReviewCard({ review, revisionCount }: ReviewCardProps) {
const isPassed = review.verdict === 'pass';
return (
<div className="rounded-md border bg-muted/20 overflow-hidden text-xs">
{/* 头部:综合评分 + 轮次 */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<div className="flex items-center gap-2">
<span className="font-medium">编辑审核结果</span>
{revisionCount !== undefined && revisionCount > 0 && (
<span className="text-muted-foreground">(第 {revisionCount} 次修改)</span>
)}
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-[10px] font-semibold',
isPassed
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
)}
>
{isPassed ? '通过' : '打回'}
</span>
<span className="font-semibold tabular-nums">
{review.overallScore}
<span className="text-muted-foreground font-normal"> / 100</span>
</span>
</div>
</div>
{/* 分项评分 */}
<div className="px-3 py-2.5 space-y-2 border-b">
<ScoreBar label="事实准确性" score={review.factScore} />
<ScoreBar label="逻辑结构" score={review.logicScore} />
<ScoreBar label="语言文字" score={review.languageScore} />
<p className="text-muted-foreground/60 text-[10px]">
通过线:{review.passThreshold} 分
</p>
</div>
{/* 修改意见 */}
{review.revisionNotes.length > 0 && (
<div className="px-3 py-2.5">
<p className="text-muted-foreground font-medium mb-2">修改意见</p>
<IssueList issues={review.revisionNotes} />
</div>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { cn } from '@/lib/utils';
interface ScoreBarProps {
label: string;
score: number;
className?: string;
}
export function ScoreBar({ label, score, className }: ScoreBarProps) {
const clipped = Math.min(100, Math.max(0, score));
const color =
clipped >= 80
? 'bg-green-500'
: clipped >= 60
? 'bg-yellow-500'
: 'bg-red-500';
const verdict =
clipped >= 80 ? '优秀' : clipped >= 60 ? '需改进' : '不合格';
return (
<div className={cn('space-y-1', className)}>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{label}</span>
<span className="font-medium tabular-nums">
{clipped}
<span className="text-muted-foreground/60 ml-1">({verdict})</span>
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${clipped}%` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import { CheckCircle2, Circle, Loader2, PauseCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ProgressEvent } from '../../types/ai-writing';
type StepStatus = 'done' | 'running' | 'paused' | 'waiting';
interface TimelineStepProps {
event: ProgressEvent;
isLast?: boolean;
children?: React.ReactNode;
}
function statusOf(event: ProgressEvent): StepStatus {
if (event.type === 'await_user') return 'paused';
if (event.type === 'step_start') return 'running';
return 'done';
}
const AGENT_COLOR: Record<string, string> = {
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
编辑: 'text-orange-500',
系统: 'text-muted-foreground',
};
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
const status = statusOf(event);
return (
<div className="flex gap-3">
{/* 竖线 + 图标 */}
<div className="flex flex-col items-center">
<StatusIcon status={status} />
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* 内容 */}
<div className={cn('pb-4 flex-1 min-w-0', isLast && 'pb-0')}>
<div className="flex items-center gap-1.5 mb-0.5">
{event.agentName && (
<span
className={cn(
'text-xs font-semibold',
AGENT_COLOR[event.agentName] ?? 'text-muted-foreground',
)}
>
{event.agentName}
</span>
)}
<span className="text-xs text-muted-foreground">{event.message}</span>
</div>
{children}
</div>
</div>
);
}
function StatusIcon({ status }: { status: StepStatus }) {
switch (status) {
case 'done':
return <CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />;
case 'running':
return <Loader2 className="size-4 text-blue-500 shrink-0 mt-0.5 animate-spin" />;
case 'paused':
return <PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />;
default:
return <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />;
}
}
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import {
@@ -6,18 +6,23 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
type LeftMode = "chat" | "writing";
export function CanvasResizableLayout() {
const { isChatCollapsed } = useCanvasUIContext();
const layoutRef = useRef<GroupImperativeHandle>(null);
const [leftMode, setLeftMode] = useState<LeftMode>("chat");
useEffect(() => {
layoutRef.current?.setLayout(
@@ -39,9 +44,29 @@ export function CanvasResizableLayout() {
minSize={0}
collapsible
collapsedSize={0}
className="overflow-hidden"
className="overflow-hidden flex flex-col"
>
<CanvasChatPanel />
{/* 模式切换标签栏 */}
<div className="flex shrink-0 border-b bg-muted/30">
{(["chat", "writing"] as LeftMode[]).map((mode) => (
<button
key={mode}
onClick={() => setLeftMode(mode)}
className={cn(
"flex-1 py-1.5 text-xs font-medium transition-colors",
leftMode === mode
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{mode === "chat" ? "对话" : "AI 写作"}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { startWritingSession, subscribeToProgress, resumeWriting } from '../api/ai-writing';
import type {
AIWritingRequest,
AIWritingState,
AIMaterial,
AIOutline,
ProgressEvent,
ReviewResult,
UserInterventionPayload,
} from '../types/ai-writing';
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
progressEvents: [],
materials: [],
outline: null,
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
revisionCount: 0,
currentPause: null,
error: null,
};
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const [state, setState] = useState<AIWritingState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const unsubRef = useRef<(() => void) | null>(null);
const handleEvent = useCallback(
(event: ProgressEvent) => {
setState((prev) => {
const base: AIWritingState = {
...prev,
progressEvents: [...prev.progressEvents, event],
};
switch (event.type) {
case 'step_start':
case 'step_done':
case 'resumed':
return event.type === 'resumed' ? { ...base, currentPause: null } : base;
case 'materials_ready':
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹)
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
};
case 'outline_ready':
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹)
return {
...base,
outline: (event.outline ?? null) as AIOutline | null,
};
case 'draft_ready':
return {
...base,
currentDraftMarkdown: event.draftMarkdown ?? '',
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
status: 'awaiting_draft_confirm',
currentPause: event,
};
case 'review_ready':
return {
...base,
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
};
case 'await_user': {
const pp = event.pausePoint;
const pauseStatus = pp
? (`awaiting_${pp}` as AIWritingState['status'])
: prev.status;
// 若 await_user 里携带了素材/大纲/审核数据,一并更新(兜底 materials_ready 丢失的情况)
const extra: Partial<AIWritingState> = {};
if (pp === 'material_confirm' && event.materials?.length) {
extra.materials = event.materials as AIMaterial[];
}
if (pp === 'outline_confirm' && event.outline) {
extra.outline = event.outline as AIOutline;
}
return { ...base, ...extra, status: pauseStatus, currentPause: event };
}
case 'done':
return { ...base, status: 'done', currentPause: null };
case 'error':
return { ...base, status: 'error', error: event.message ?? '未知错误' };
default:
return base;
}
});
// 草稿实时同步到右侧 Artifact 面板
if (event.type === 'draft_ready' && event.draftMarkdown) {
setArtifact({
currentIndex: 0,
contents: [
{
index: 0,
type: 'text',
title: event.draftTitle ?? '草稿',
fullMarkdown: event.draftMarkdown,
},
],
});
}
},
[setArtifact],
);
const startWriting = useCallback(
async (request: AIWritingRequest) => {
if (
state.status !== 'idle' &&
state.status !== 'done' &&
state.status !== 'error'
)
return;
abortRef.current?.abort();
abortRef.current = new AbortController();
setState({ ...INITIAL_STATE, status: 'researching' });
try {
const sessionId = await startWritingSession(request);
setState((prev) => ({ ...prev, sessionId }));
unsubRef.current = subscribeToProgress(
sessionId,
handleEvent,
abortRef.current.signal,
);
} catch (err) {
setState((prev) => ({
...prev,
status: 'error',
error: String(err),
}));
}
},
[state.status, handleEvent],
);
const submitIntervention = useCallback(
async (payload: UserInterventionPayload) => {
if (!state.sessionId) return;
try {
await resumeWriting(state.sessionId, payload);
setState((prev) => ({ ...prev, currentPause: null }));
} catch (err) {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId],
);
const stopWriting = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setState((prev) => ({ ...prev, status: 'idle' }));
}, []);
const reset = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setArtifact(null);
setState(INITIAL_STATE);
}, [setArtifact]);
useEffect(() => () => { abortRef.current?.abort(); }, []);
return { state, startWriting, submitIntervention, stopWriting, reset };
}
@@ -0,0 +1,138 @@
// ── 素材 ─────────────────────────────────────────────────────────────────────
export interface AIMaterial {
id: string;
title: string;
content: string;
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet';
url?: string;
recUuid?: string;
}
// ── 大纲 ─────────────────────────────────────────────────────────────────────
export interface OutlineSection {
sectionTitle: string;
keyPoints: string[];
materialIds: string[];
}
export interface AIOutline {
title: string;
sections: OutlineSection[];
}
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
export interface ReviewIssue {
category: 'fact' | 'logic' | 'language';
severity: 'major' | 'minor';
location: string;
issue: string;
suggestion: string;
}
export interface ReviewResult {
verdict: 'pass' | 'reject';
factScore: number;
logicScore: number;
languageScore: number;
overallScore: number;
passThreshold: number;
revisionNotes: ReviewIssue[];
}
// ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus =
| 'idle'
| 'researching'
| 'awaiting_material_confirm'
| 'writing_outline'
| 'awaiting_outline_confirm'
| 'writing_draft'
| 'awaiting_draft_confirm'
| 'reviewing'
| 'awaiting_review_confirm'
| 'revising'
| 'done'
| 'error';
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
export type ProgressEventType =
| 'connected'
| 'step_start'
| 'step_done'
| 'materials_ready'
| 'outline_ready'
| 'draft_chunk'
| 'draft_ready'
| 'review_ready'
| 'await_user'
| 'resumed'
| 'done'
| 'error';
export interface ProgressEvent {
type: ProgressEventType;
sessionId: string;
agentName?: string;
message: string;
// materials_ready
materials?: AIMaterial[];
keywords?: string[];
materialSummary?: string;
// outline_ready
outline?: AIOutline;
// draft_ready / draft_chunk
draftMarkdown?: string;
draftTitle?: string;
revisionCount?: number;
// review_ready
reviewResult?: ReviewResult;
// await_user
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
pauseContext?: Record<string, unknown>;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
export interface UserInterventionPayload {
action: string;
approvedMaterialIds?: string[];
extraKeywords?: string[];
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
overrideVerdict?: boolean;
}
// ── 写作请求 ──────────────────────────────────────────────────────────────────
export interface AIWritingRequest {
userIntent: string;
articleType: 'news' | 'analysis' | 'feature' | 'report';
targetAudience: string;
wordCountTarget: number;
modelName?: string;
}
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
progressEvents: ProgressEvent[];
materials: AIMaterial[];
outline: AIOutline | null;
currentDraftMarkdown: string;
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
currentPause: ProgressEvent | null;
error: string | null;
}