ai写作
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user