第11环裂变重写:对齐上线版 split.js 一次LLM出N套完整笔记包

架构从"扇出N个GenerationTask各跑完整管道"改为"一次LLM调用直接出N套
完整笔记包(N=1~5)",落 FissionNote 表 + 独立展示页。

后端:
- 018迁移:fission_notes 表(文案JSON+score+passed+imagePlan+images+status)
- fission_prompt:FISSION_SYSTEM+三档参考度(low/mid/high)+normalize_tags+品类兜底
- fission_pipeline:一次LLM出N套→各评分(@80合格线)→排序→落库,不达标标
  needs_optimization 非丢弃;apiports 503 回落 codeproxy gpt-5.5 强档兜底
- fission_images:每套串行调现有生图接口(零改动image_gen/storyboard)
- tasks.py:run_fission_pipeline Celery task,删旧扇出注入
- api/v1/fission:进度聚合FissionNote + GET /fission/{id}/notes(剥内部字段)

前端:FissionProgress对齐状态机 + N套独立展示页 + FissionNoteCard

测试:test_fission_engine(19)+test_fission_pipeline(5) 全过;104 全量回归绿

实测task5(fanout=2,mid)端到端跑通:一次出2套→seq0=85过/seq1=79标优化→
生图codeproxy/edits→1024×1536去AI化→task completed→notes端点返完整数据

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 11:17:37 +08:00
parent 7f419f4c8b
commit d85dcd401b
18 changed files with 1772 additions and 106 deletions

View File

@@ -0,0 +1,112 @@
'use client';
/**
* FissionLauncher — 裂变发起面板
* 选源爆款 + 参考强度(low/mid/high) + 扇出套数(1~5) → POST /fission回调 fission_id。
*/
import { useState } from 'react';
import { api } from '@/lib/api';
import { TaskListItem } from '@/types';
import { getErrorAction } from '@/types/errors';
import { clsx } from 'clsx';
interface Props {
sources: TaskListItem[];
onLaunched: (fissionId: number) => void;
}
const LEVELS: { key: string; label: string; desc: string }[] = [
{ key: 'low', label: '低', desc: '只借结构,文案视觉大改' },
{ key: 'mid', label: '中', desc: '保留核心卖点,角度换新' },
{ key: 'high', label: '高', desc: '贴近原作,仅做差异微调' },
];
export function FissionLauncher({ sources, onLaunched }: Props) {
const [sourceId, setSourceId] = useState<number | null>(sources[0]?.id ?? null);
const [level, setLevel] = useState('mid');
const [fanout, setFanout] = useState(3);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function launch() {
if (!sourceId) { setError('请选择一个源爆款'); return; }
setSubmitting(true);
setError(null);
try {
const res = await api.post<{ fission_id: number }>('/api/v1/fission', {
source_task_id: sourceId, reference_level: level, fanout_count: fanout,
});
onLaunched(res.fission_id);
} catch (e: unknown) {
const ae = e as { code?: number; message?: string };
setError(ae.message || getErrorAction(ae.code ?? 50001).message);
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-5">
{/* 选源爆款 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<select
value={sourceId ?? ''}
onChange={(e) => setSourceId(Number(e.target.value))}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
>
{sources.map((t) => (
<option key={t.id} value={t.id}>
#{t.id} · {t.theme || '(无主题)'}
</option>
))}
</select>
</div>
{/* 参考强度 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<div className="grid grid-cols-3 gap-2">
{LEVELS.map((l) => (
<button
key={l.key}
type="button"
onClick={() => setLevel(l.key)}
className={clsx(
'border rounded-lg p-3 text-left transition-colors',
level === l.key
? 'border-brand-orange bg-brand-orange-light'
: 'border-border-default hover:border-brand-orange'
)}
>
<div className="text-sm font-medium text-text-primary">{l.label}</div>
<div className="text-xs text-text-tertiary mt-1">{l.desc}</div>
</button>
))}
</div>
</div>
{/* 扇出套数 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-brand-orange font-semibold">{fanout}</span>
</label>
<input
type="range" min={1} max={5} value={fanout}
onChange={(e) => setFanout(Number(e.target.value))}
className="w-full accent-brand-orange"
/>
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<button
type="button"
onClick={launch}
disabled={submitting || !sourceId}
className="self-start bg-brand-orange text-white text-sm font-medium px-6 py-2.5 rounded-lg hover:bg-orange-600 disabled:opacity-50"
>
{submitting ? '裂变中…' : `裂变成 ${fanout} 套笔记包 →`}
</button>
</div>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
/**
* FissionNoteCard — 单套裂变笔记包卡片F2 子组件)
* 展示:维度/分数徽章 + 标题 + 正文 + 标签 + 生成图 + imagePlan 排版说明。
*/
import type { FissionNoteItem } from '@/app/fission/[id]/notes/page';
interface Props {
item: FissionNoteItem;
}
// 图片 url 后端给 /uploads/... 相对路径,前端同源直接用
function imgSrc(url?: string): string | null {
if (!url) return null;
return url.startsWith('/uploads/') ? url : `/uploads/${url.replace(/^.*\/uploads\//, '')}`;
}
export function FissionNoteCard({ item }: Props) {
const { note, images, score, passed, needs_optimization, dimension, seq } = item;
const okImages = images.filter((im) => im.url && !im.error);
const failedImages = images.filter((im) => im.error);
return (
<div className="rounded-xl border border-border-default bg-surface-primary p-5 flex flex-col gap-4">
{/* 头部:序号 + 维度 + 分数 */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary"> {seq} </span>
{(dimension || note.dimension) && (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-tertiary text-text-secondary">
{dimension || note.dimension}
</span>
)}
</div>
<div className="flex items-center gap-2">
{score != null && (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
passed ? 'bg-green-50 text-green-600' : 'bg-amber-50 text-amber-600'
}`}>
{score}
</span>
)}
{needs_optimization && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 text-amber-600">
</span>
)}
</div>
</div>
{/* 标题 + 封面钩子 */}
{note.coverTitle && (
<p className="text-sm font-medium text-brand-orange">{note.coverTitle}</p>
)}
{note.title && (
<h3 className="text-base font-semibold text-text-primary">{note.title}</h3>
)}
{/* 正文 */}
{note.content && (
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">
{note.content}
</p>
)}
{/* 标签 */}
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{note.tags.map((t, i) => (
<span key={i} className="text-xs text-brand-orange">#{t}</span>
))}
</div>
)}
{/* 生成图 */}
{okImages.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{okImages.map((im) => (
<div key={im.seq} className="flex flex-col gap-1">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc(im.url) || ''}
alt={im.role}
className="w-full aspect-[3/4] object-cover rounded-lg border border-border-default"
/>
<span className="text-[11px] text-text-tertiary text-center">{im.role}</span>
</div>
))}
</div>
)}
{failedImages.length > 0 && (
<p className="text-xs text-amber-600">
{failedImages.length}
</p>
)}
{/* imagePlan 排版说明(运营按此排版/叠字) */}
{note.imagePlan && note.imagePlan.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-text-secondary">
{note.imagePlan.length}
</summary>
<ol className="mt-2 flex flex-col gap-2 list-decimal list-inside">
{note.imagePlan.map((p, i) => (
<li key={i} className="text-xs text-text-tertiary">
<span className="text-text-secondary">{p.role || `${i + 1}`}</span>
{p.title && <> · {p.title}</>}
{p.overlayText && <> · {p.overlayText}</>}
{p.text && <> · {p.text}</>}
</li>
))}
</ol>
</details>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
/**
* FissionProgress — 裂变进度(轮询 GET /fission/{id} 聚合 x/N 完成)
* 新架构:一次 LLM 出 N 套完整笔记包落 FissionNote完成后进独立展示页看 N 套。
*/
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { api } from '@/lib/api';
interface FissionStatus {
fission_id: number;
reference_level: string;
fanout_count: number;
status: 'generating' | 'completed' | 'failed';
progress: { done: number; failed: number; total: number };
}
interface Props {
fissionId: number;
onReset: () => void;
}
export function FissionProgress({ fissionId, onReset }: Props) {
const [data, setData] = useState<FissionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const poll = useCallback(async () => {
try {
const res = await api.get<FissionStatus>(`/api/v1/fission/${fissionId}`);
setData(res);
return res.status;
} catch {
setError('查询裂变进度失败');
return 'failed';
}
}, [fissionId]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
let stopped = false;
const tick = async () => {
const status = await poll();
if (!stopped && status === 'generating') timer = setTimeout(tick, 3000);
};
tick();
return () => { stopped = true; clearTimeout(timer); };
}, [poll]);
if (error) {
return (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error}
<button className="ml-4 underline" onClick={onReset}></button>
</div>
);
}
if (!data) return <div className="py-12 text-center text-text-tertiary text-sm"></div>;
const { progress, status } = data;
const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-text-primary">
#{data.fission_id} · {data.fanout_count}
</h2>
<span className="text-sm text-text-secondary">
{progress.done}/{progress.total || data.fanout_count}
{progress.failed > 0 && <span className="text-red-500"> · {progress.failed} </span>}
</span>
</div>
<div className="w-full h-2 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-brand-orange transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
{status === 'generating' && (
<p className="text-sm text-text-tertiary"> {data.fanout_count} </p>
)}
{status === 'failed' && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-600">
</div>
)}
{status === 'completed' && (
<Link
href={`/fission/${data.fission_id}/notes`}
className="self-start rounded-lg bg-brand-orange text-white px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
>
{data.fanout_count}
</Link>
)}
<button
type="button"
onClick={onReset}
className="self-start text-sm text-text-secondary underline mt-2"
>
</button>
</div>
);
}