AI 小说生成工作台:分层流水线 + 故事圣经(一期完整实现)

- 后端:Node/Express + node:sqlite,人物/世界观/分卷/细纲/正文 8 阶段生成流水线(SSE 流式 + zod 校验 + 重试)
- 前端:React 18 + Vite + antd 三栏工作台:流水线引导、章节树、编辑器(快切章节/段落改写)、故事圣经面板
- 写作工艺注入:人物声口/内在冲突/秘密,细纲 POV/情绪曲线/功能/张力,正文专业规则
- 自动回写故事圣经(摘要/设定/人物变化/伏笔/时间线),TXT/EPUB 导出,token 成本日志
This commit is contained in:
Administrator
2026-09-13 20:35:47 +08:00
commit 4073edcc20
49 changed files with 9827 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './pages/HomePage'
import WorkbenchPage from './pages/WorkbenchPage'
dayjs.locale('zh-cn')
export default function App() {
return (
<ConfigProvider
locale={zhCN}
theme={{
token: { colorPrimary: '#7c3aed', borderRadius: 6 }
}}
>
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/workbench/:id" element={<WorkbenchPage />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
)
}
+93
View File
@@ -0,0 +1,93 @@
async function request<T>(path: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined
})
if (!res.ok) {
let msg = `HTTP ${res.status}`
try {
const j = await res.json()
if (j?.error) msg = j.error
} catch {
/* ignore */
}
throw new Error(msg)
}
return res.json() as Promise<T>
}
export const apiGet = <T,>(p: string) => request<T>(p, 'GET')
export const apiPost = <T,>(p: string, body?: unknown) => request<T>(p, 'POST', body ?? {})
export const apiPatch = <T,>(p: string, body: unknown) => request<T>(p, 'PATCH', body)
export const apiPut = <T,>(p: string, body: unknown) => request<T>(p, 'PUT', body)
export const apiDelete = <T,>(p: string) => request<T>(p, 'DELETE')
export interface SseDoneMessage {
content?: string
data?: unknown
usage?: { prompt_tokens?: number; completion_tokens?: number }
}
export interface SseHandlers {
signal?: AbortSignal
onDelta?: (t: string) => void
onDone?: (m: SseDoneMessage) => void
onError?: (msg: string) => void
}
/** POST + SSE 流式响应解析(delta/done/error 三种事件) */
export async function ssePost(path: string, body: unknown, h: SseHandlers): Promise<void> {
let res: Response
try {
res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: h.signal
})
} catch (e) {
if ((e as Error).name !== 'AbortError') h.onError?.((e as Error).message)
return
}
if (!res.ok || !res.body) {
let msg = `HTTP ${res.status}`
try {
const j = await res.json()
if (j?.error) msg = j.error
} catch {
/* ignore */
}
h.onError?.(msg)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let idx: number
while ((idx = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, idx)
buf = buf.slice(idx + 2)
let event = 'message'
let data = ''
for (const line of chunk.split('\n')) {
if (line.startsWith('event: ')) event = line.slice(7)
else if (line.startsWith('data: ')) data += line.slice(6)
}
if (!data) continue
let msg: Record<string, unknown>
try {
msg = JSON.parse(data)
} catch {
continue
}
if (event === 'delta') h.onDelta?.(msg.text as string)
else if (event === 'done') h.onDone?.(msg as SseDoneMessage)
else if (event === 'error') h.onError?.(msg.message as string)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
export interface Project {
id: number
name: string
genre: string
premise: string
style_notes: string
style_sample: string
narrative_pov: string
taboos: string
target_chapter_words: number
target_chapter_count: number
created_at: string
updated_at: string
total_words?: number
chapter_count?: number
written_count?: number
}
export interface Character {
id: number
project_id: number
name: string
aliases: string
role: string
appearance: string
personality: string
background: string
goals: string
speech_pattern: string
inner_conflict: string
secret: string
arc_notes: string
sort: number
}
export interface WorldEntry {
id: number
project_id: number
category: string
title: string
content: string
sort: number
}
export interface Volume {
id: number
project_id: number
title: string
summary: string
sort: number
}
export interface Beat {
id: number
project_id: number
volume_id: number | null
sort: number
title: string
events: string
involved_characters: number[]
foreshadow_plan: string
pov: string
emotional_arc: string
purpose: string
tension: number
}
export interface Chapter {
id: number
project_id: number
beat_id: number | null
volume_id: number | null
sort: number
title: string
content: string
status: 'empty' | 'generated' | 'edited'
summary: string
word_count: number
updated_at: string
}
export interface Foreshadow {
id: number
project_id: number
description: string
planted_chapter_id: number | null
paid_chapter_id: number | null
status: 'planted' | 'paid'
notes: string
}
export interface TimelineEvent {
id: number
project_id: number
chapter_id: number | null
story_time: string
title: string
description: string
sort: number
}
export interface LlmSettings {
id: number
base_url: string
api_key: string
model: string
temperature: number
max_tokens: number
}
export interface Bundle {
project: Project
characters: Character[]
world: WorldEntry[]
volumes: Volume[]
beats: Beat[]
chapters: Chapter[]
foreshadows: Foreshadow[]
timeline: TimelineEvent[]
}
export interface PostprocessResult {
summary: string
new_world_facts: number
character_updates: number
new_foreshadows: number
paid_foreshadows: number
timeline_events: number
}
export interface GenState {
stage: string
label: string
text: string
done: boolean
}
export interface RewriteReq {
mode: 'rewrite' | 'expand' | 'shrink' | 'custom'
instruction?: string
selected: string
before: string
after: string
chapterId: number
start: number
end: number
}
+48
View File
@@ -0,0 +1,48 @@
import { Input } from 'antd'
import { useEffect, useState } from 'react'
/** 受控可编辑文本:外部值变化时同步,失焦时若变更则触发保存 */
export default function AutoSaveText({
value,
onSave,
multiline = false,
placeholder,
minRows = 2,
maxRows,
style,
bordered = true,
size
}: {
value: string
onSave: (next: string) => void
multiline?: boolean
placeholder?: string
minRows?: number
maxRows?: number
style?: React.CSSProperties
bordered?: boolean
size?: 'small' | 'middle' | 'large'
}) {
const [v, setV] = useState(value)
useEffect(() => setV(value), [value])
const commit = () => {
if (v !== value) onSave(v)
}
const common = {
value: v,
placeholder,
variant: bordered ? ('outlined' as const) : ('borderless' as const),
size,
style,
onChange: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => setV(e.target.value),
onBlur: commit,
onPressEnter: (e: React.KeyboardEvent) => {
if (!multiline) {
;(e.target as HTMLInputElement).blur()
}
}
}
return multiline ? <Input.TextArea autoSize={{ minRows, maxRows: maxRows ?? minRows + 6 }} {...common} /> : <Input {...common} />
}
+150
View File
@@ -0,0 +1,150 @@
import { DeleteOutlined, ThunderboltOutlined, EditOutlined } from '@ant-design/icons'
import { Button, Card, Empty, Popconfirm, Rate, Select, Space, Tag, message } from 'antd'
import { apiDelete, apiPatch } from '../api/client'
import type { Bundle } from '../api/types'
import AutoSaveText from './AutoSaveText'
const PURPOSES = ['开局', '铺垫', '升温', '转折', '高潮', '回落', '收束', '过渡']
/** 分章细纲:按卷查看/编辑每章的事件、人物与伏笔安排 */
export default function BeatsView({
bundle,
volumeId,
setVolumeId,
running,
onGenerate,
onChanged,
onWriteChapter
}: {
bundle: Bundle
volumeId: number | null
setVolumeId: (id: number | null) => void
running: boolean
onGenerate: (volumeId: number) => void
onChanged: () => void
onWriteChapter: (chapterId: number) => void
}) {
const volume = bundle.volumes.find((v) => v.id === volumeId) ?? bundle.volumes[0]
const activeId = volume?.id ?? null
const beats = bundle.beats.filter((b) => b.volume_id === activeId).sort((a, b) => a.sort - b.sort)
const saveBeat = (id: number, patch: Record<string, unknown>) => apiPatch(`/api/beats/${id}`, patch).catch((e) => message.error(e.message))
if (bundle.volumes.length === 0) {
return <Empty description="先生成或手动创建分卷大纲(大纲页),再为每卷生成分章细纲" style={{ marginTop: 60 }} />
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<Select
value={activeId ?? undefined}
onChange={(v) => setVolumeId(v as number)}
style={{ minWidth: 220 }}
options={bundle.volumes.map((v, i) => ({ value: v.id, label: `第${i + 1}卷 ${v.title}` }))}
/>
<span style={{ color: '#999', fontSize: 13 }}>{volume?.summary?.slice(0, 60)}{volume && volume.summary.length > 60 ? '…' : ''}</span>
<div style={{ flex: 1 }} />
<Button type="primary" icon={<ThunderboltOutlined />} loading={running} disabled={!activeId} onClick={() => activeId && onGenerate(activeId)}>
{beats.length > 0 ? '重新生成本卷细纲' : 'AI 生成本卷细纲'}
</Button>
</div>
{beats.length === 0 ? (
<Empty description="本卷还没有分章细纲" style={{ marginTop: 40 }} />
) : (
beats.map((b, i) => {
const chapter = bundle.chapters.find((c) => c.beat_id === b.id)
return (
<Card
key={b.id}
size="small"
title={
<Space>
<Tag color="purple">{i + 1}</Tag>
<AutoSaveText value={b.title} onSave={(t) => saveBeat(b.id, { title: t })} bordered={false} style={{ fontWeight: 600, minWidth: 200 }} />
</Space>
}
extra={
<Space>
{chapter && (
<Button
size="small"
type={chapter.content.trim() ? 'default' : 'primary'}
icon={<EditOutlined />}
onClick={() => onWriteChapter(chapter.id)}
>
{chapter.content.trim() ? '查看正文' : '写正文'}
</Button>
)}
<Popconfirm title="删除该细纲?(对应章节保留)" onConfirm={() => apiDelete(`/api/beats/${b.id}`).then(onChanged)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
}
>
<AutoSaveText
value={b.events}
onSave={(t) => saveBeat(b.id, { events: t })}
multiline
minRows={2}
placeholder="本章事件:冲突、转折、结尾钩子"
style={{ color: '#444', marginBottom: 8 }}
/>
<Space wrap size={8} style={{ marginBottom: 8, rowGap: 4 }}>
<span style={{ color: '#999', fontSize: 12 }}>视角</span>
<Select
size="small"
allowClear
placeholder="视角人物"
style={{ minWidth: 110 }}
value={b.pov || undefined}
onChange={(v) => saveBeat(b.id, { pov: v ?? '' })}
options={bundle.characters.map((c) => ({ value: c.name, label: c.name }))}
/>
<span style={{ color: '#999', fontSize: 12 }}>功能</span>
<Select
size="small"
allowClear
placeholder="章节功能"
style={{ width: 96 }}
value={b.purpose || undefined}
onChange={(v) => saveBeat(b.id, { purpose: v ?? '' })}
options={PURPOSES.map((p) => ({ value: p, label: p }))}
/>
<span style={{ color: '#999', fontSize: 12 }}>张力</span>
<Rate count={5} value={b.tension} onChange={(v) => saveBeat(b.id, { tension: v })} style={{ fontSize: 14 }} />
<AutoSaveText
value={b.emotional_arc}
onSave={(t) => saveBeat(b.id, { emotional_arc: t })}
placeholder="情绪曲线:警惕→动摇→决意"
bordered={false}
style={{ color: '#7c3aed', minWidth: 200 }}
/>
</Space>
<Space wrap>
<Select
mode="multiple"
allowClear
placeholder="涉及人物"
style={{ minWidth: 260 }}
value={b.involved_characters}
onChange={(ids) => saveBeat(b.id, { involved_characters: ids as number[] })}
options={bundle.characters.map((c) => ({ value: c.id, label: `${c.name}(${c.role})` }))}
/>
<div style={{ flex: 1, minWidth: 260 }}>
<AutoSaveText
value={b.foreshadow_plan}
onSave={(t) => saveBeat(b.id, { foreshadow_plan: t })}
placeholder="伏笔安排(埋下 / 回收)"
bordered={false}
style={{ color: '#9966cc' }}
/>
</div>
</Space>
</Card>
)
})
)}
</div>
)
}
+298
View File
@@ -0,0 +1,298 @@
import { CheckCircleOutlined, EditOutlined, ExpandOutlined, LeftOutlined, RightOutlined, ScissorOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Button, Empty, Input, Popover, Select, Space, Tag, Tooltip, Typography, message } from 'antd'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Bundle, Chapter, RewriteReq } from '../api/types'
/** 章节正文编辑器:自动保存 + 选中段落重写/扩写/缩写 + 章节快速导航 */
export default function ChapterEditor({
bundle,
chapter,
running,
onPatchChapter,
onGenerateProse,
onRewrite,
onSwitchChapter
}: {
bundle: Bundle
chapter: Chapter | undefined
running: boolean
onPatchChapter: (patch: { id: number; content?: string; title?: string }) => Promise<unknown>
onGenerateProse: (chapterId: number) => void
onRewrite: (req: RewriteReq) => void
onSwitchChapter: (chapterId: number) => void
}) {
const [content, setContent] = useState(chapter?.content ?? '')
const [title, setTitle] = useState(chapter?.title ?? '')
const [sel, setSel] = useState<{ start: number; end: number } | null>(null)
const [customOpen, setCustomOpen] = useState(false)
const [customText, setCustomText] = useState('')
const taRef = useRef<HTMLTextAreaElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const dirtyRef = useRef(false)
const contentRef = useRef(content)
const chapterIdRef = useRef(chapter?.id)
const chapterId = chapter?.id
useEffect(() => {
contentRef.current = content
}, [content])
// 切换章节:重置本地编辑态;离开前把未保存内容立即落库
useEffect(() => {
chapterIdRef.current = chapterId
setContent(chapter?.content ?? '')
setTitle(chapter?.title ?? '')
dirtyRef.current = false
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (dirtyRef.current && chapterIdRef.current) {
dirtyRef.current = false
void onPatchChapter({ id: chapterIdRef.current, content: contentRef.current })
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapterId])
// 外部内容更新(如采纳生成结果)时同步;用户正在编辑则不打扰
useEffect(() => {
if (!dirtyRef.current) setContent(chapter?.content ?? '')
}, [chapter?.content])
const beat = useMemo(() => (chapter?.beat_id ? bundle.beats.find((b) => b.id === chapter.beat_id) : undefined), [bundle.beats, chapter?.beat_id])
const chapterNo = useMemo(() => bundle.chapters.findIndex((c) => c.id === chapterId) + 1, [bundle.chapters, chapterId])
// 章节快速导航
const idx = useMemo(() => bundle.chapters.findIndex((c) => c.id === chapterId), [bundle.chapters, chapterId])
const prevChapter = idx > 0 ? bundle.chapters[idx - 1] : null
const nextChapter = idx >= 0 && idx < bundle.chapters.length - 1 ? bundle.chapters[idx + 1] : null
const chapterOptions = useMemo(
() =>
bundle.chapters.map((c, i) => ({
value: c.id,
// 细纲生成的标题通常已带「第N章」前缀,避免重复编号
label: /^第\s*\d+\s*章/.test(c.title) ? c.title : `第${i + 1}章 ${c.title}`
})),
[bundle.chapters]
)
const scheduleSave = (next: string) => {
dirtyRef.current = true
if (timerRef.current) clearTimeout(timerRef.current)
if (!chapterId) return
timerRef.current = setTimeout(() => {
onPatchChapter({ id: chapterId, content: next })
.then(() => {
if (contentRef.current === next) dirtyRef.current = false
})
.catch((e) => message.error('保存失败:' + e.message))
}, 800)
}
const flushSave = async () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (chapterId && dirtyRef.current) {
dirtyRef.current = false
await onPatchChapter({ id: chapterId, content })
}
}
if (!chapter) {
return (
<div style={{ display: 'grid', placeItems: 'center', flex: 1, minHeight: 300 }}>
<Empty description="从左侧选择或新建章节;生成细纲后会自动建立章节" />
</div>
)
}
const captureSelection = () => {
const ta = taRef.current
if (!ta) return
const { selectionStart, selectionEnd } = ta
setSel(selectionEnd > selectionStart ? { start: selectionStart, end: selectionEnd } : null)
}
const doRewrite = (mode: RewriteReq['mode'], instruction?: string) => {
if (!sel || !content.slice(sel.start, sel.end).trim()) return
void flushSave().then(() => {
onRewrite({
mode,
instruction,
selected: content.slice(sel.start, sel.end),
before: content.slice(Math.max(0, sel.start - 300), sel.start),
after: content.slice(sel.end, sel.end + 300),
chapterId: chapter.id,
start: sel.start,
end: sel.end
})
})
}
const selectedText = sel ? content.slice(sel.start, sel.end) : ''
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<Space.Compact>
<Tooltip title={prevChapter ? `上一章:${prevChapter.title}` : '没有上一章'}>
<Button size="small" icon={<LeftOutlined />} disabled={!prevChapter || running} onClick={() => prevChapter && onSwitchChapter(prevChapter.id)} />
</Tooltip>
<Select
size="small"
showSearch
optionFilterProp="label"
value={chapterId}
style={{ minWidth: 170 }}
onChange={onSwitchChapter}
options={chapterOptions}
popupMatchSelectWidth={false}
/>
<Tooltip title={nextChapter ? `下一章:${nextChapter.title}` : '没有下一章'}>
<Button size="small" icon={<RightOutlined />} disabled={!nextChapter || running} onClick={() => nextChapter && onSwitchChapter(nextChapter.id)} />
</Tooltip>
</Space.Compact>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => title !== chapter.title && onPatchChapter({ id: chapter.id, title })}
style={{ maxWidth: 380, fontWeight: 600 }}
placeholder="章节标题"
/>
<Tag>{chapterNo > 0 ? `第${chapterNo}章` : ''}</Tag>
<Tag color={chapter.status === 'empty' ? 'default' : chapter.status === 'generated' ? 'purple' : 'green'}>
{chapter.status === 'empty' ? '未写' : chapter.status === 'generated' ? 'AI 稿' : '已编辑'}
</Tag>
<span style={{ color: '#999', fontSize: 12 }}>{[...content.replace(/\s+/g, '')].length} 字</span>
<div style={{ flex: 1 }} />
<Button
type="primary"
icon={<ThunderboltOutlined />}
loading={running}
onClick={() => {
void flushSave().then(() => onGenerateProse(chapter.id))
}}
>
{chapter.content.trim() ? '重新生成本章' : '生成本章正文'}
</Button>
</div>
{beat && (
<div style={{ background: '#faf5ff', border: '1px solid #eee', borderRadius: 6, padding: '6px 12px', marginBottom: 8, fontSize: 13, color: '#555' }}>
<b style={{ color: '#7c3aed' }}>本章细纲:</b>
<span
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
color: '#666',
verticalAlign: 'bottom'
}}
>
{beat.events || '(无事件描述)'}
</span>
<div style={{ marginTop: 4, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{beat.pov && <Tag color="purple" style={{ marginInlineEnd: 0 }}>视角:{beat.pov}</Tag>}
{beat.purpose && <Tag color="geekblue" style={{ marginInlineEnd: 0 }}>功能:{beat.purpose}</Tag>}
{beat.tension > 0 && (
<Tag color="orange" style={{ marginInlineEnd: 0 }}>
张力:{'●'.repeat(beat.tension)}{'○'.repeat(5 - beat.tension)}
</Tag>
)}
{beat.emotional_arc && <Tag color="cyan" style={{ marginInlineEnd: 0 }}>情绪:{beat.emotional_arc}</Tag>}
{beat.foreshadow_plan && <Tag color="gold" style={{ marginInlineEnd: 0 }}>伏笔:{beat.foreshadow_plan}</Tag>}
</div>
</div>
)}
{sel && selectedText.trim() && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#f6f6f6', borderRadius: 6, padding: '6px 10px', marginBottom: 8, fontSize: 13 }}>
<span style={{ color: '#888' }}>
已选中 {selectedText.trim().length} 字:
</span>
<Space size={4}>
<Tooltip title="保持情节不变,提升文笔">
<Button size="small" icon={<EditOutlined />} disabled={running} onClick={() => doRewrite('rewrite')}>重写</Button>
</Tooltip>
<Tooltip title="补充细节,扩至 1.5-2 倍">
<Button size="small" icon={<ExpandOutlined />} disabled={running} onClick={() => doRewrite('expand')}>扩写</Button>
</Tooltip>
<Tooltip title="压缩至一半左右">
<Button size="small" icon={<ScissorOutlined />} disabled={running} onClick={() => doRewrite('shrink')}>缩写</Button>
</Tooltip>
<Popover
open={customOpen}
onOpenChange={setCustomOpen}
trigger={['click']}
content={
<div style={{ width: 280 }}>
<Input.TextArea
rows={2}
value={customText}
onChange={(e) => setCustomText(e.target.value)}
placeholder="例:改成第一人称;增加环境描写;语气更刻薄……"
/>
<Button
size="small"
type="primary"
style={{ marginTop: 8 }}
disabled={running || !customText.trim()}
onClick={() => {
setCustomOpen(false)
doRewrite('custom', customText.trim())
}}
>
执行改写
</Button>
</div>
}
>
<Button size="small" disabled={running}>自定义指令…</Button>
</Popover>
</Space>
<span style={{ color: '#bbb', fontSize: 12 }}>生成结果会出现在右侧抽屉中,确认后替换</span>
</div>
)}
<textarea
ref={taRef}
value={content}
onChange={(e) => {
setContent(e.target.value)
scheduleSave(e.target.value)
}}
onSelect={captureSelection}
onKeyUp={captureSelection}
placeholder="章节正文将生成或书写在这里。也可以先写好内容,再选中段落让 AI 重写。"
style={{
flex: 1,
minHeight: 300,
resize: 'none',
border: '1px solid #e5e5e5',
borderRadius: 6,
padding: 16,
fontSize: 15,
lineHeight: 2,
fontFamily: '"Microsoft YaHei", serif',
outline: 'none'
}}
/>
{chapter.summary && (
<div style={{ marginTop: 8, fontSize: 12, color: '#888', display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<CheckCircleOutlined style={{ color: '#52c41a', marginTop: 2 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<b>前情摘要(已注入后续章节生成):</b>
<Typography.Paragraph
type="secondary"
style={{ marginBottom: 0, display: 'inline' }}
ellipsis={{ rows: 1, expandable: true, symbol: '展开' }}
>
{chapter.summary}
</Typography.Paragraph>
</div>
</div>
)}
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { DeleteOutlined, FileAddOutlined, FolderAddOutlined } from '@ant-design/icons'
import { Button, Input, Modal, Popconfirm, Tag, Tree, message } from 'antd'
import type { DataNode } from 'antd/es/tree'
import { useState } from 'react'
import { apiDelete, apiPost } from '../api/client'
import type { Bundle } from '../api/types'
const STATUS_COLOR: Record<string, string> = { empty: '#d9d9d9', generated: '#7c3aed', edited: '#52c41a' }
const STATUS_TEXT: Record<string, string> = { empty: '未写', generated: 'AI 稿', edited: '已编辑' }
export default function ChapterTree({
bundle,
chapterId,
running,
onSelectChapter,
onSelectVolume,
onChanged
}: {
bundle: Bundle
chapterId: number | null
running: boolean
onSelectChapter: (id: number) => void
onSelectVolume: (id: number) => void
onChanged: () => void
}) {
const [newVolOpen, setNewVolOpen] = useState(false)
const [newVolName, setNewVolName] = useState('')
const [newChapterVol, setNewChapterVol] = useState<number | null | undefined>(undefined) // undefined=关闭, null=未分卷
const [newChapterTitle, setNewChapterTitle] = useState('')
const pid = bundle.project.id
const chapterNo = new Map(bundle.chapters.map((c, i) => [c.id, i + 1]))
const buildTree = (): DataNode[] => {
const chapterNode = (c: Bundle['chapters'][number]): DataNode => ({
key: `c-${c.id}`,
title: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, maxWidth: 230 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{chapterNo.get(c.id)}. {c.title}
</span>
<Tag color={STATUS_COLOR[c.status]} style={{ marginInlineEnd: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
{STATUS_TEXT[c.status]}
</Tag>
<Popconfirm title="删除该章节?" onConfirm={() => apiDelete(`/api/chapters/${c.id}`).then(onChanged)} onCancel={(e) => e?.stopPropagation()}>
<DeleteOutlined
style={{ color: '#bbb', fontSize: 12 }}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</span>
)
})
const nodes: DataNode[] = bundle.volumes.map((v) => ({
key: `v-${v.id}`,
title: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<b>{v.title}</b>
<FileAddOutlined
style={{ color: '#999' }}
onClick={(e) => {
e.stopPropagation()
setNewChapterVol(v.id)
setNewChapterTitle('')
}}
/>
<Popconfirm title="删除该卷?(章节保留为未分卷)" onConfirm={() => apiDelete(`/api/volumes/${v.id}`).then(onChanged)}>
<DeleteOutlined style={{ color: '#bbb', fontSize: 12 }} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
</span>
),
children: bundle.chapters.filter((c) => c.volume_id === v.id).map(chapterNode)
}))
const orphans = bundle.chapters.filter((c) => c.volume_id === null)
if (orphans.length > 0) nodes.push({ key: 'v-none', title: <b>未分卷</b>, children: orphans.map(chapterNode) })
return nodes
}
const createChapter = async () => {
if (!newChapterTitle.trim()) return
const r = await apiPost<{ id: number }>(`/api/projects/${pid}/chapters`, {
volume_id: newChapterVol,
beat_id: null,
title: newChapterTitle.trim(),
sort: bundle.chapters.length + 1
})
setNewChapterVol(undefined)
onChanged()
onSelectChapter(r.id)
message.success('章节已创建')
}
return (
<div style={{ padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<b>章节树</b>
<span style={{ display: 'flex', gap: 4 }}>
<Button size="small" icon={<FolderAddOutlined />} disabled={running} onClick={() => { setNewVolName(`第${bundle.volumes.length + 1}卷`); setNewVolOpen(true) }}>
卷
</Button>
<Button size="small" icon={<FileAddOutlined />} disabled={running} onClick={() => { setNewChapterVol(null); setNewChapterTitle('新章节') }}>
章
</Button>
</span>
</div>
{bundle.chapters.length === 0 && bundle.volumes.length === 0 ? (
<div style={{ color: '#999', fontSize: 12, padding: '8px 0' }}>
还没有章节。请先生成大纲与细纲,或手动新建。
</div>
) : (
<Tree
blockNode
defaultExpandAll
expandAction={false}
selectedKeys={chapterId ? [`c-${chapterId}`] : []}
onSelect={(keys, info) => {
const key = String(keys[0] ?? '')
if (key.startsWith('c-')) onSelectChapter(Number(key.slice(2)))
else if (key.startsWith('v-') && key !== 'v-none') onSelectVolume(Number(key.slice(2)))
void info
}}
treeData={buildTree()}
/>
)}
<Modal
title="新建卷"
open={newVolOpen}
onCancel={() => setNewVolOpen(false)}
onOk={async () => {
if (!newVolName.trim()) return
await apiPost(`/api/projects/${pid}/volumes`, { title: newVolName.trim(), summary: '', sort: bundle.volumes.length + 1 })
setNewVolOpen(false)
onChanged()
}}
>
<Input value={newVolName} onChange={(e) => setNewVolName(e.target.value)} placeholder="卷名" />
</Modal>
<Modal
title={newChapterVol === null ? '新建章节(未分卷)' : '新建章节'}
open={newChapterVol !== undefined}
onCancel={() => setNewChapterVol(undefined)}
onOk={createChapter}
>
<Input value={newChapterTitle} onChange={(e) => setNewChapterTitle(e.target.value)} placeholder="章节标题,如:第1章 异变" />
</Modal>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useRef } from 'react'
import { Alert, Button, Drawer, Space, Tag, Typography } from 'antd'
import type { GenState } from '../api/types'
/** JSON 结构化阶段:抽屉里不展示原始 JSON,只展示可读的实时进度 */
const STRUCTURED_STAGES = new Set(['characters', 'world', 'volumes', 'beats'])
const NAME_PATTERNS: Record<string, RegExp> = {
characters: /"name"\s*:\s*"([^"]+)"/g,
world: /"title"\s*:\s*"([^"]+)"/g,
volumes: /"title"\s*:\s*"([^"]+)"/g,
beats: /"title"\s*:\s*"([^"]+)"/g
}
/** 提取标题中的序号(卷一/第1卷/第12章):模型输出多版本方案时只保留第一套 */
function ordinalOf(name: string, stage: string): string | null {
const t = name.replace(/\s+/g, '')
if (stage === 'beats') return /^第([0-90-9一二三四五六七八九十百]+)[章回]/.exec(t)?.[1] ?? null
if (stage === 'volumes') {
const num = '[0-90-9一二三四五六七八九十百]+'
const m = new RegExp(`^(?:第)?(${num})[卷部篇]`).exec(t) ?? new RegExp(`^[卷部篇](${num})`).exec(t)
return m?.[1] ?? null
}
return null
}
const normKey = (s: string) => s.replace(/[\s::||·、,,。.\-—~~()()【】[\]]+/g, '')
function extractNames(stage: string, text: string): string[] {
const re = NAME_PATTERNS[stage]
if (!re) return []
const out: string[] = []
const seenOrd = new Set<string>()
const seenText = new Set<string>()
for (const m of text.matchAll(re)) {
const name = m[1].trim()
if (!name) continue
const ord = ordinalOf(name, stage)
if (ord) {
if (seenOrd.has(ord)) continue
seenOrd.add(ord)
} else {
const key = normKey(name)
if (seenText.has(key)) continue
seenText.add(key)
}
out.push(name)
}
return out
}
const STAGE_UNIT: Record<string, string> = { characters: '人物', world: '条设定', volumes: '卷', beats: '章细纲' }
export default function GenerateDrawer({
gen,
running,
onAccept,
onDiscard,
onAbort
}: {
gen: GenState | null
running: boolean
onAccept: () => void
onDiscard: () => void
onAbort: () => void
}) {
const bodyRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [gen?.text])
const structured = gen ? STRUCTURED_STAGES.has(gen.stage) : false
const names = useMemo(() => (gen && structured ? extractNames(gen.stage, gen.text) : []), [gen, structured])
const unit = gen ? (STAGE_UNIT[gen.stage] ?? '项') : ''
const canAccept = !!gen && (!structured || gen.done)
return (
<Drawer
open={!!gen}
onClose={() => {
if (running) onAbort()
onDiscard()
}}
title={gen?.label}
width={520}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{gen && structured && !gen.done ? `已输出 ${[...gen.text.replace(/\s/g, '')].length} 字…` : ''}
</Typography.Text>
<Space>
{running ? (
<Button onClick={onAbort}>中断生成</Button>
) : (
<>
<Button onClick={onDiscard}>丢弃</Button>
<Button type="primary" disabled={!canAccept} onClick={onAccept}>
采纳
</Button>
</>
)}
</Space>
</div>
}
>
<div ref={bodyRef} style={{ height: '100%', overflow: 'auto' }}>
{gen && structured ? (
<>
<Alert
type={gen.done ? 'success' : 'info'}
showIcon={false}
message={
gen.done
? `生成完成:共 ${names.length} ${unit}。检查无误后点击「采纳」保存入库。`
: `AI 正在生成${unit}…已出现 ${names.length} 项`
}
style={{ marginBottom: 16 }}
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{names.map((n, i) => (
<Tag key={`${n}-${i}`} color={gen.done ? 'purple' : 'default'}>
{n}
</Tag>
))}
</div>
{running && <span style={{ color: '#7c3aed' }}>▌</span>}
</>
) : (
<>
<pre className="stream-pre">{gen?.text}</pre>
{running && <span style={{ color: '#7c3aed' }}>▌</span>}
</>
)}
</div>
</Drawer>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { Form, Input, InputNumber, Modal, Select, Space, message } from 'antd'
import { apiPost } from '../api/client'
import type { Project } from '../api/types'
const GENRES = ['玄幻', '仙侠', '都市', '科幻', '悬疑', '历史', '言情', '奇幻', '武侠', '游戏', '其他']
export default function NewProjectModal({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (p: Project) => void }) {
const [form] = Form.useForm()
const submit = async () => {
try {
const values = await form.validateFields()
const p = await apiPost<Project>('/api/projects', values)
onCreated(p)
form.resetFields()
} catch (e) {
// 表单校验错误(有 message 字段)已由表单展示,这里只提示服务端错误
if ((e as { errorFields?: unknown }).errorFields === undefined) message.error((e as Error).message)
throw e
}
}
return (
<Modal
title="新建作品"
open={open}
onCancel={onClose}
onOk={submit}
okText="创建并进入工作台"
width={640}
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={{ target_chapter_words: 3000, target_chapter_count: 30 }}>
<Form.Item name="name" label="作品名" rules={[{ required: true, message: '请输入作品名' }]}>
<Input placeholder="例:星海遗民" />
</Form.Item>
<Form.Item name="genre" label="题材">
<Select placeholder="选择题材" allowClear options={GENRES.map((g) => ({ value: g, label: g }))} />
</Form.Item>
<Form.Item
name="premise"
label="故事创意 / 基础设定(可粘贴长文:背景、主角、核心冲突、期望走向、已有设定均可)"
rules={[{ required: true, message: '请描述你的故事创意' }]}
extra="支持粘贴数万字以内的设定材料,AI 会在生成时提取要点"
>
<Input.TextArea rows={6} placeholder="例:一艘世代飞船上,主角发现了飞船早已抵达目的地、所谓航行只是统治谎言的证据……" />
</Form.Item>
<Form.Item name="style_notes" label="文风要求(可选)">
<Input.TextArea rows={2} placeholder="例:克制的冷峻笔调,多对话少心理描写,类似《三体》的硬朗感" />
</Form.Item>
<Form.Item name="style_sample" label="文风示例片段(可选,AI 会模仿其笔法)">
<Input.TextArea rows={3} placeholder="粘贴一段你喜欢文风的示例文字(300-800 字效果最佳)" />
</Form.Item>
<Form.Item name="narrative_pov" label="叙事视角(可选)" extra="正文生成时会严格执行视角纪律,不漂移">
<Select
placeholder="选择或留空由 AI 酌情处理"
allowClear
options={['第三人称限知', '第三人称全知', '第一人称', '多视角轮转'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name="taboos" label="禁用内容(可选)" extra="例:不要重生穿越;不要系统流;不出现真实政治人物">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label="目标规模" required style={{ marginBottom: 0 }}>
<Space>
<Form.Item name="target_chapter_count" noStyle>
<InputNumber min={1} max={2000} addonAfter="章" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="target_chapter_words" noStyle>
<InputNumber min={200} max={20000} addonAfter="字/章" style={{ width: 160 }} />
</Form.Item>
</Space>
</Form.Item>
</Form>
</Modal>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Button, Card, Empty, Input, Popconfirm, message } from 'antd'
import { apiDelete, apiPatch, apiPost } from '../api/client'
import type { Bundle } from '../api/types'
import AutoSaveText from './AutoSaveText'
/** 分卷大纲:编辑卷信息 + AI 生成总纲 */
export default function OutlineView({
bundle,
running,
onGenerate,
onChanged
}: {
bundle: Bundle
running: boolean
onGenerate: () => void
onChanged: () => void
}) {
const pid = bundle.project.id
const saveVolume = (id: number, patch: Record<string, unknown>) => apiPatch(`/api/volumes/${id}`, patch).catch((e) => message.error(e.message))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#888', fontSize: 13 }}>
大纲决定整本书的骨架。目标 {bundle.project.target_chapter_count} 章 · 生成后可在下方直接修改,再到「细纲」页生成分章细纲
</span>
<Button type="primary" icon={<ThunderboltOutlined />} loading={running} onClick={onGenerate}>
{bundle.volumes.length > 0 ? '重新生成总纲' : 'AI 生成分卷大纲'}
</Button>
</div>
{bundle.volumes.length === 0 ? (
<Empty description="还没有分卷大纲" style={{ marginTop: 60 }} />
) : (
bundle.volumes.map((v, i) => (
<Card
key={v.id}
size="small"
title={`第${i + 1}卷`}
extra={
<Popconfirm title="删除该卷?(章节保留为未分卷)" onConfirm={() => apiDelete(`/api/volumes/${v.id}`).then(onChanged)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
}
>
<div style={{ marginBottom: 6 }}>
<AutoSaveText value={v.title} onSave={(t) => saveVolume(v.id, { title: t })} placeholder="卷名" bordered={false} style={{ fontWeight: 700, fontSize: 15 }} />
</div>
<AutoSaveText value={v.summary} onSave={(s) => saveVolume(v.id, { summary: s })} multiline minRows={2} placeholder="本卷大纲:核心冲突、关键事件、结尾钩子" style={{ color: '#555' }} />
<div style={{ marginTop: 6, color: '#bbb', fontSize: 12 }}>
本卷 {bundle.chapters.filter((c) => c.volume_id === v.id).length} 章(生成细纲后自动创建)
</div>
</Card>
))
)}
{bundle.volumes.length > 0 && (
<Button
style={{ alignSelf: 'flex-start' }}
onClick={async () => {
await apiPost(`/api/projects/${pid}/volumes`, { title: `第${bundle.volumes.length + 1}卷`, summary: '', sort: bundle.volumes.length + 1 })
onChanged()
}}
>
手动添加一卷
</Button>
)}
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { ThunderboltOutlined } from '@ant-design/icons'
import { Button, Steps, Tag } from 'antd'
import type { Bundle } from '../api/types'
type GuideTab = 'prose' | 'outline' | 'beats' | null
interface StepDef {
key: string
title: string
desc: string
done: boolean
tab: GuideTab
ai: string
you: string
actions?: { label: string; primary?: boolean; onClick: () => void }[]
}
/** 常驻流水线引导:每一步都标明「AI 生成什么 / 人工审核什么」,完成一步自动亮起下一步 */
export default function PipelineGuide({
bundle,
running,
onGoto,
onGenerateCharacters,
onGenerateWorld
}: {
bundle: Bundle
running: boolean
onGoto: (tab: 'prose' | 'outline' | 'beats') => void
onGenerateCharacters: () => void
onGenerateWorld: () => void
}) {
const written = bundle.chapters.filter((c) => c.content.trim()).length
const steps: StepDef[] = [
{
key: 'bible',
title: '1 人物与世界观',
desc: bundle.characters.length + bundle.world.length > 0 ? `${bundle.characters.length} 人物 · ${bundle.world.length} 设定` : '待生成',
done: bundle.characters.length > 0 || bundle.world.length > 0,
tab: null,
ai: '根据故事创意生成人物卡与世界观候选',
you: '在右侧「故事圣经」逐项审核修改——这是全书一致性的地基',
actions: [
{ label: '生成人物卡', primary: true, onClick: onGenerateCharacters },
{ label: '生成世界观', onClick: onGenerateWorld }
]
},
{
key: 'volumes',
title: '2 分卷大纲',
desc: bundle.volumes.length > 0 ? `${bundle.volumes.length} 卷` : '待生成',
done: bundle.volumes.length > 0,
tab: 'outline',
ai: '基于人物与世界观搭出分卷骨架与主线节奏',
you: '在「大纲」页审核每卷走向并直接改文字;不满意可带要求重新生成',
actions: [{ label: '去大纲页生成', primary: true, onClick: () => onGoto('outline') }]
},
{
key: 'beats',
title: '3 分章细纲',
desc: bundle.beats.length > 0 ? `${bundle.beats.length} 章` : '待生成',
done: bundle.beats.length > 0,
tab: 'beats',
ai: '把每卷拆成逐章细纲:事件 / 涉及人物 / 伏笔安排',
you: '在「细纲」页逐章把关:调整事件顺序、涉及人物与伏笔节奏',
actions: [{ label: '去细纲页生成', primary: true, onClick: () => onGoto('beats') }]
},
{
key: 'prose',
title: '4 逐章正文',
desc: written > 0 ? `已写 ${written} 章` : '待写作',
done: written > 0,
tab: 'prose',
ai: '注入人物卡、前情摘要、伏笔与上一章结尾,逐章流式生成',
you: '选中段落用「重写 / 扩写 / 缩写」微调,正文只需少量人工修订',
actions: [{ label: '去写正文', primary: true, onClick: () => onGoto('prose') }]
},
{
key: 'export',
title: '5 回写与导出',
desc: 'TXT / EPUB',
done: written > 0,
tab: null,
ai: '每章生成后自动回写摘要、新设定、伏笔与时间线',
you: '在右侧「故事圣经」核对伏笔与时间线,完成后从右上角导出'
}
]
const currentIdx = steps.findIndex((s) => !s.done)
const cur = currentIdx === -1 ? null : steps[currentIdx]
return (
<div style={{ background: '#fff', borderBottom: '1px solid #f0f0f0', padding: '10px 20px 10px' }}>
<Steps
size="small"
current={currentIdx === -1 ? steps.length : currentIdx}
onChange={(i) => {
const t = steps[i]?.tab
if (t) onGoto(t)
}}
items={steps.map((s) => ({ title: s.title, description: s.desc }))}
/>
{cur ? (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', fontSize: 13, lineHeight: '22px' }}>
<Tag color="purple" style={{ marginInlineEnd: 0 }}>AI</Tag>
<span style={{ color: '#555' }}>{cur.ai}</span>
<Tag color="green" style={{ marginInlineEnd: 0 }}>你</Tag>
<span style={{ color: '#555' }}>{cur.you}</span>
<div style={{ flex: 1 }} />
{cur.actions?.map((a) => (
<Button
key={a.label}
size="small"
type={a.primary ? 'primary' : 'default'}
icon={<ThunderboltOutlined />}
disabled={running}
onClick={a.onClick}
>
{a.label}
</Button>
))}
</div>
) : (
<div style={{ marginTop: 6, fontSize: 13, color: '#52c41a' }}>
✓ 全流程已就绪:继续逐章写作与微调,随时可从右上角导出 TXT / EPUB
</div>
)}
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { Alert, Button, Form, Input, InputNumber, Modal, Spin } from 'antd'
import { useEffect, useState } from 'react'
import { apiGet, apiPost, apiPut } from '../api/client'
import type { LlmSettings } from '../api/types'
interface TestResult {
ok: boolean
reply?: string
models?: string[]
error?: string
}
export default function SettingsModal({ open, onClose, onSaved }: { open: boolean; onClose: () => void; onSaved?: () => void }) {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
useEffect(() => {
if (!open) return
setTestResult(null)
setLoading(true)
apiGet<LlmSettings>('/api/settings')
.then((s) => form.setFieldsValue(s))
.catch((e) => e)
.finally(() => setLoading(false))
}, [open, form])
const runTest = async () => {
try {
const values = await form.validateFields()
setTesting(true)
setTestResult(null)
const r = await apiPost<TestResult>('/api/settings/test', {
base_url: values.base_url,
api_key: values.api_key,
model: values.model
})
setTestResult(r)
} catch (e) {
setTestResult({ ok: false, error: (e as Error).message })
} finally {
setTesting(false)
}
}
const save = async () => {
const values = await form.validateFields()
await apiPut('/api/settings', values)
onSaved?.()
onClose()
}
return (
<Modal title="模型接入设置" open={open} onCancel={onClose} width={640}
footer={[
<Button key="test" onClick={runTest} loading={testing}>测试连接</Button>,
<Button key="cancel" onClick={onClose}>取消</Button>,
<Button key="save" type="primary" onClick={save}>保存</Button>
]}
>
<Spin spinning={loading}>
<Alert
style={{ marginBottom: 16 }}
type="info"
showIcon
message="支持任意 OpenAI 兼容接口(火山方舟 / DeepSeek / OpenAI / 本地 Ollama 等)。保存后即可生成。"
/>
<Form form={form} layout="vertical" initialValues={{ temperature: 0.8, max_tokens: 12000 }}>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true, message: '请输入 Base URL' }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" />
</Form.Item>
<Form.Item name="api_key" label="API Key" rules={[{ required: true, message: '请输入 API Key' }]}>
<Input.Password placeholder="ark-..." visibilityToggle />
</Form.Item>
<Form.Item name="model" label="模型名" rules={[{ required: true, message: '请输入模型名' }]} extra="火山方舟填模型名(如 doubao-seed-1-6-flash);其他服务商按其文档填写">
<Input placeholder="doubao-seed-1-6-flash" />
</Form.Item>
<Form.Item name="temperature" label="温度(越高越有创造性)">
<InputNumber min={0} max={2} step={0.1} style={{ width: 160 }} />
</Form.Item>
<Form.Item name="max_tokens" label="单次最大输出 tokens" extra="章节正文较长,建议保持 12000 以上">
<InputNumber min={256} max={65536} step={256} style={{ width: 160 }} />
</Form.Item>
</Form>
{testResult && (
<Alert
type={testResult.ok ? 'success' : 'error'}
showIcon
message={testResult.ok ? `连接成功,模型回复:${testResult.reply || '(空)'}` : `连接失败:${testResult.error}`}
description={testResult.ok && testResult.models ? `可用模型(前 ${testResult.models.length} 个):${testResult.models.join('、') || '(无)'}` : undefined}
style={{ marginTop: 8 }}
/>
)}
</Spin>
</Modal>
)
}
+349
View File
@@ -0,0 +1,349 @@
import { DeleteOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Button, Collapse, Empty, Input, Popconfirm, Select, Space, Tabs, Tag, Timeline, message } from 'antd'
import { useState } from 'react'
import { apiDelete, apiPatch, apiPost } from '../api/client'
import type { Bundle } from '../api/types'
import AutoSaveText from './AutoSaveText'
const WORLD_CATEGORIES = ['地理', '势力', '力量体系', '物品', '规则', '历史', '其他']
const ROLES = ['主角', '重要配角', '配角', '反派']
const oneLine = (s: string, n = 30) => {
const t = (s || '').replace(/\s+/g, ' ').trim()
return t.length > n ? t.slice(0, n) + '…' : t
}
/** 故事圣经:人物卡 / 世界观 / 伏笔 / 时间线,条目默认收起,点击展开编辑 */
export default function StoryBible({ bundle, onChanged, running }: { bundle: Bundle; onChanged: () => void; running?: boolean }) {
const pid = bundle.project.id
const [expanded, setExpanded] = useState<string[]>([])
const [backfilling, setBackfilling] = useState(false)
const guard = (p: Promise<unknown>) =>
p.then(onChanged).catch((e) => {
message.error(e.message)
throw e
})
const toggle = (key: string) =>
setExpanded((cur) => (cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key]))
// 旧版生成的卡片缺少声口 / 内在冲突 / 秘密,可一键让 AI 补全(只填空字段)
const missingCount = bundle.characters.filter((c) => !c.speech_pattern.trim() || !c.inner_conflict.trim() || !c.secret.trim()).length
const backfillCraft = async () => {
setBackfilling(true)
const hide = message.loading(`AI 正在为 ${missingCount} 位人物补写声口 / 内在冲突 / 秘密…`, 0)
try {
const r = await apiPost<{ updated: number; message?: string }>('/api/generate/backfill/characters', { projectId: pid })
if (r.updated === 0) message.info(r.message ?? '所有人物卡均已补全')
else message.success(`已为 ${r.updated} 位人物补全专业字段,展开人物卡查看修改`)
onChanged()
} catch (e) {
message.error('补全失败:' + (e as Error).message)
} finally {
hide()
setBackfilling(false)
}
}
const charactersTab = (
<div style={{ padding: '0 2px 12px' }}>
{missingCount > 0 && (
<Button
block
size="small"
icon={<ThunderboltOutlined />}
style={{ marginBottom: 8 }}
loading={backfilling}
disabled={running}
onClick={backfillCraft}
>
AI 补全声口 / 内在冲突 / 秘密(待补 {missingCount} 人)
</Button>
)}
{bundle.characters.length === 0 && <Empty description="暂无人物卡" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{bundle.characters.map((c) => {
const key = `c${c.id}`
const open = expanded.includes(key)
return (
<Collapse
key={c.id}
size="small"
style={{ marginBottom: 6, border: '1px solid #f0f0f0', borderRadius: 6 }}
activeKey={open ? [key] : []}
onChange={() => toggle(key)}
items={[
{
key,
label: (
<Space size={6}>
<b>{c.name}</b>
<Tag color={c.role === '主角' ? 'purple' : c.role === '反派' ? 'red' : 'default'} style={{ marginInlineEnd: 0 }}>
{c.role}
</Tag>
<span style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{oneLine(c.goals || c.personality)}</span>
</Space>
),
extra: (
<Popconfirm
title="删除该人物?"
onConfirm={(e) => {
e?.stopPropagation()
guard(apiDelete(`/api/characters/${c.id}`))
}}
onCancel={(e) => e?.stopPropagation()}
>
<DeleteOutlined style={{ color: '#bbb' }} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
),
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 13 }}>
<Space.Compact block>
<Input
defaultValue={c.name}
onBlur={(e) => e.target.value !== c.name && guard(apiPatch(`/api/characters/${c.id}`, { name: e.target.value }))}
addonBefore="姓名"
size="small"
/>
<Select
defaultValue={c.role}
size="small"
style={{ width: 110 }}
onChange={(role) => guard(apiPatch(`/api/characters/${c.id}`, { role }))}
options={ROLES.map((r) => ({ value: r, label: r }))}
/>
</Space.Compact>
{(
[
['aliases', '别称'],
['appearance', '外貌'],
['personality', '性格'],
['background', '背景'],
['goals', '目标(表层目标与深层需求)'],
['speech_pattern', '语言习惯 / 口癖(保证声口一致)'],
['inner_conflict', '内在冲突(欲望 vs 恐惧)'],
['secret', '秘密(不为人知之事)']
] as const
).map(([field, label]) => (
<div key={field}>
<div style={{ color: '#999', fontSize: 12 }}>{label}</div>
<AutoSaveText
value={c[field]}
onSave={(v) => guard(apiPatch(`/api/characters/${c.id}`, { [field]: v }))}
multiline={field !== 'aliases'}
minRows={2}
bordered={false}
placeholder={label}
style={{ fontSize: 13 }}
/>
</div>
))}
<div>
<div style={{ color: '#999', fontSize: 12 }}>弧光与近况(AI 生成正文后自动追加)</div>
<AutoSaveText
value={c.arc_notes}
onSave={(v) => guard(apiPatch(`/api/characters/${c.id}`, { arc_notes: v }))}
multiline
minRows={2}
bordered={false}
placeholder="人物成长线与最新状态"
style={{ fontSize: 12, color: '#666' }}
/>
</div>
</div>
)
}
]}
/>
)
})}
<Button
block
icon={<PlusOutlined />}
onClick={() => guard(apiPost(`/api/projects/${pid}/characters`, { name: '新人物', role: '配角', sort: bundle.characters.length + 1 }))}
>
添加人物
</Button>
</div>
)
// 动态分组:预置分类靠前,AI 自创分类按出现顺序排在后面;空分类归入「未分类」,保证所有条目可见
const worldGroups = (() => {
const catOf = (w: Bundle['world'][number]) => (w.category || '').trim() || '未分类'
const cats: string[] = []
bundle.world.forEach((w) => {
const c = catOf(w)
if (!cats.includes(c)) cats.push(c)
})
cats.sort((a, b) => {
const ia = WORLD_CATEGORIES.indexOf(a)
const ib = WORLD_CATEGORIES.indexOf(b)
return (ia === -1 ? WORLD_CATEGORIES.length : ia) - (ib === -1 ? WORLD_CATEGORIES.length : ib)
})
return cats.map((cat) => ({ cat, items: bundle.world.filter((w) => catOf(w) === cat) }))
})()
const worldTab = (
<div style={{ padding: '0 2px 12px' }}>
{bundle.world.length === 0 && <Empty description="暂无世界观设定" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{worldGroups.map((g) => (
<div key={g.cat} style={{ marginBottom: 10 }}>
<Tag color="geekblue">{g.cat}</Tag>
<Collapse
size="small"
ghost
items={g.items.map((w) => ({
key: `w${w.id}`,
label: (
<span>
<b style={{ fontSize: 13 }}>{w.title}</b>
<span style={{ color: '#999', fontSize: 12, marginLeft: 8 }}>{oneLine(w.content)}</span>
</span>
),
extra: <Popconfirm title="删除该设定?" onConfirm={() => guard(apiDelete(`/api/world/${w.id}`))}><DeleteOutlined style={{ color: '#bbb', fontSize: 12 }} /></Popconfirm>,
children: (
<div>
<AutoSaveText value={w.title} onSave={(t) => guard(apiPatch(`/api/world/${w.id}`, { title: t }))} bordered={false} style={{ fontWeight: 600 }} placeholder="条目名" />
<AutoSaveText
value={w.content}
onSave={(v) => guard(apiPatch(`/api/world/${w.id}`, { content: v }))}
multiline
minRows={3}
bordered={false}
placeholder="具体设定描述"
style={{ fontSize: 12, color: '#666' }}
/>
</div>
)
}))}
/>
</div>
))}
<Button
block
icon={<PlusOutlined />}
onClick={() => guard(apiPost(`/api/projects/${pid}/world`, { title: '新设定', category: '其他', content: '', sort: bundle.world.length + 1 }))}
>
添加设定
</Button>
</div>
)
const chapterTitle = (id: number | null) => {
if (!id) return ''
const idx = bundle.chapters.findIndex((c) => c.id === id)
return idx >= 0 ? `第${idx + 1}章《${bundle.chapters[idx].title}》` : ''
}
const foreshadowTab = (
<div style={{ padding: '0 2px 12px' }}>
{bundle.foreshadows.length === 0 && <Empty description="暂无伏笔。正文中 AI 侦测到伏笔会自动登记" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
<Collapse
size="small"
items={bundle.foreshadows.map((f) => ({
key: `f${f.id}`,
label: (
<Space size={6}>
<Tag color={f.status === 'planted' ? 'orange' : 'green'} style={{ marginInlineEnd: 0 }}>
{f.status === 'planted' ? '未回收' : '已回收'}
</Tag>
<span style={{ fontSize: 13 }}>{oneLine(f.description, 26)}</span>
</Space>
),
extra: <Popconfirm title="删除该伏笔?" onConfirm={() => guard(apiDelete(`/api/foreshadows/${f.id}`))}><DeleteOutlined style={{ color: '#bbb', fontSize: 12 }} /></Popconfirm>,
children: (
<div>
<AutoSaveText value={f.description} onSave={(v) => guard(apiPatch(`/api/foreshadows/${f.id}`, { description: v }))} multiline minRows={2} bordered={false} style={{ fontSize: 13 }} placeholder="伏笔描述" />
<Space style={{ marginTop: 4 }}>
<Select
size="small"
value={f.status}
style={{ width: 92 }}
onChange={(status) => guard(apiPatch(`/api/foreshadows/${f.id}`, { status }))}
options={[
{ value: 'planted', label: '未回收' },
{ value: 'paid', label: '已回收' }
]}
/>
<span style={{ fontSize: 11, color: '#bbb' }}>
{chapterTitle(f.planted_chapter_id)} 埋设{f.status === 'paid' ? ` · ${chapterTitle(f.paid_chapter_id)} 回收` : ''}
</span>
</Space>
</div>
)
}))}
/>
<Button
block
icon={<PlusOutlined />}
style={{ marginTop: 8 }}
onClick={() => guard(apiPost(`/api/projects/${pid}/foreshadows`, { description: '新伏笔:', status: 'planted' }))}
>
手动登记伏笔
</Button>
</div>
)
const timelineTab = (
<div style={{ padding: '0 2px 12px' }}>
{bundle.timeline.length === 0 && <Empty description="暂无时间线事件。正文生成后 AI 会自动记录关键事件" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{bundle.timeline.length > 0 && (
<Timeline
mode="left"
items={bundle.timeline.map((t) => ({
color: t.chapter_id ? 'purple' : 'gray',
label: (
<Input
defaultValue={t.story_time}
size="small"
placeholder="故事时间"
style={{ width: 100 }}
onBlur={(e) => e.target.value !== t.story_time && guard(apiPatch(`/api/timeline/${t.id}`, { story_time: e.target.value }))}
/>
),
children: (
<div style={{ paddingBottom: 2 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<AutoSaveText value={t.title} onSave={(v) => guard(apiPatch(`/api/timeline/${t.id}`, { title: v }))} bordered={false} style={{ fontWeight: 600, flex: 1 }} placeholder="事件名" />
<Popconfirm title="删除该事件?" onConfirm={() => guard(apiDelete(`/api/timeline/${t.id}`))}>
<DeleteOutlined style={{ color: '#bbb', fontSize: 12 }} />
</Popconfirm>
</div>
<AutoSaveText
value={t.description}
onSave={(v) => guard(apiPatch(`/api/timeline/${t.id}`, { description: v }))}
multiline
minRows={1}
bordered={false}
placeholder="事件详情"
style={{ fontSize: 12, color: '#666' }}
/>
<div style={{ fontSize: 11, color: '#bbb' }}>{chapterTitle(t.chapter_id)}</div>
</div>
)
}))}
/>
)}
<Button
block
icon={<PlusOutlined />}
style={{ marginTop: 8 }}
onClick={() => guard(apiPost(`/api/projects/${pid}/timeline`, { title: '新事件', story_time: '', sort: bundle.timeline.length + 1 }))}
>
添加事件
</Button>
</div>
)
return (
<div style={{ padding: '6px 12px 16px' }}>
<Tabs
items={[
{ key: 'characters', label: `人物 ${bundle.characters.length}`, children: charactersTab },
{ key: 'world', label: `世界观 ${bundle.world.length}`, children: worldTab },
{ key: 'foreshadows', label: `伏笔 ${bundle.foreshadows.length}`, children: foreshadowTab },
{ key: 'timeline', label: `时间线 ${bundle.timeline.length}`, children: timelineTab }
]}
/>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
html,
body,
#root {
height: 100%;
margin: 0;
}
* {
box-sizing: border-box;
}
.stream-pre {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 13px;
line-height: 1.8;
margin: 0;
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import 'antd/dist/reset.css'
import './global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+122
View File
@@ -0,0 +1,122 @@
import { BookOutlined, DeleteOutlined, DownloadOutlined, PlusOutlined } from '@ant-design/icons'
import { Button, Card, Dropdown, Empty, Popconfirm, Progress, Tag, message } from 'antd'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiDelete, apiGet } from '../api/client'
import type { Project } from '../api/types'
import NewProjectModal from '../components/NewProjectModal'
import SettingsModal from '../components/SettingsModal'
export default function HomePage() {
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
const [newOpen, setNewOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const navigate = useNavigate()
const load = () => {
setLoading(true)
apiGet<Project[]>('/api/projects')
.then(setProjects)
.catch((e) => message.error(e.message))
.finally(() => setLoading(false))
}
useEffect(load, [])
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 26 }}>
<BookOutlined style={{ color: '#7c3aed', marginRight: 10 }} />
智能小说工作台
</h1>
<div style={{ color: '#888', marginTop: 6 }}>从一句话创意到完整长篇:人物 · 世界观 · 大纲 · 细纲 · 正文,全程可控可改</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={() => setSettingsOpen(true)}>模型设置</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNewOpen(true)}>
新建作品
</Button>
</div>
</div>
{loading ? null : projects.length === 0 ? (
<Empty description="还没有作品,点击右上角「新建作品」开始" style={{ marginTop: 120 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 16 }}>
{projects.map((p) => {
const written = p.written_count ?? 0
const total = p.chapter_count ?? 0
const percent = total > 0 ? Math.round((written / Math.max(total, p.target_chapter_count)) * 100) : 0
return (
<Card
key={p.id}
hoverable
onClick={() => navigate(`/workbench/${p.id}`)}
title={<span style={{ fontSize: 16 }}>{p.name}</span>}
extra={
<span onClick={(e) => e.stopPropagation()} style={{ display: 'inline-flex', gap: 4 }}>
<Dropdown
menu={{
items: [
{ key: 'txt', icon: <DownloadOutlined />, label: '导出 TXT' },
{ key: 'epub', icon: <DownloadOutlined />, label: '导出 EPUB' }
],
onClick: ({ key }) => window.open(`/api/export/${p.id}/${key}`)
}}
>
<Button type="text" size="small" icon={<DownloadOutlined />} />
</Dropdown>
<Popconfirm
title="删除作品"
description="将删除该项目全部数据,确定?"
onConfirm={(e) => {
e?.stopPropagation()
apiDelete(`/api/projects/${p.id}`).then(load)
}}
onCancel={(e) => e?.stopPropagation()}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
</span>
}
>
{p.genre && <Tag color="purple">{p.genre}</Tag>}
<div
style={{
color: '#666',
fontSize: 13,
margin: '8px 0 12px',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
minHeight: 40
}}
>
{p.premise || '(未填写故事创意)'}
</div>
<Progress percent={percent} size="small" format={() => `${written}/${Math.max(total, p.target_chapter_count)} 章`} />
<div style={{ color: '#999', fontSize: 12, marginTop: 8 }}>
总字数 {(p.total_words ?? 0).toLocaleString()} · 每章目标 {p.target_chapter_words} 字 · 更新于 {p.updated_at.slice(0, 10)}
</div>
</Card>
)
})}
</div>
)}
<NewProjectModal
open={newOpen}
onClose={() => setNewOpen(false)}
onCreated={(p) => {
setNewOpen(false)
navigate(`/workbench/${p.id}`)
}}
/>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} onSaved={() => message.success('模型设置已保存')} />
</div>
)
}
+410
View File
@@ -0,0 +1,410 @@
import { ArrowLeftOutlined, EditOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons'
import { Button, Dropdown, Form, Input, InputNumber, Layout, Modal, Segmented, Select, Tag, Tooltip, message } from 'antd'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { apiGet, apiPatch, apiPost, ssePost } from '../api/client'
import type { Bundle, Chapter, GenState, PostprocessResult, Project, RewriteReq } from '../api/types'
import ChapterTree from '../components/ChapterTree'
import ChapterEditor from '../components/ChapterEditor'
import GenerateDrawer from '../components/GenerateDrawer'
import OutlineView from '../components/OutlineView'
import BeatsView from '../components/BeatsView'
import PipelineGuide from '../components/PipelineGuide'
import SettingsModal from '../components/SettingsModal'
import StoryBible from '../components/StoryBible'
export default function WorkbenchPage() {
const { id } = useParams()
const pid = Number(id)
const navigate = useNavigate()
const [messageApi, messageContextHolder] = message.useMessage()
const [bundle, setBundle] = useState<Bundle | null>(null)
const [tab, setTab] = useState<'prose' | 'outline' | 'beats'>('prose')
const [chapterId, setChapterId] = useState<number | null>(null)
const [beatsVolumeId, setBeatsVolumeId] = useState<number | null>(null)
const [gen, setGen] = useState<GenState | null>(null)
const [running, setRunning] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [editForm] = Form.useForm()
const abortRef = useRef<AbortController | null>(null)
const acceptRef = useRef<((content: string, data: unknown) => void) | null>(null)
const doneDataRef = useRef<unknown>(null)
// 「先约束后生成」:所有 AI 生成入口先弹要求弹窗(可选),再启动生成
const [reqOpen, setReqOpen] = useState(false)
const [reqTitle, setReqTitle] = useState('')
const [reqText, setReqText] = useState('')
const pendingRunRef = useRef<((requirement: string) => void) | null>(null)
const lastReqRef = useRef<Record<string, string>>({})
const askRequirement = (key: string, title: string, run: (requirement: string) => void) => {
setReqTitle(title)
setReqText(lastReqRef.current[key] ?? '')
pendingRunRef.current = (req) => {
lastReqRef.current[key] = req
run(req)
}
setReqOpen(true)
}
const reload = useCallback(async () => {
const b = await apiGet<Bundle>(`/api/projects/${pid}/bundle`)
setBundle(b)
return b
}, [pid])
useEffect(() => {
reload().catch((e) => messageApi.error(e.message))
}, [pid, reload, messageApi])
// 默认选中第一章
useEffect(() => {
if (bundle && chapterId === null && bundle.chapters.length > 0) setChapterId(bundle.chapters[0].id)
}, [bundle, chapterId])
const updateChapterLocal = useCallback((ch: Partial<Bundle['chapters'][number]> & { id: number }) => {
setBundle((b) => (b ? { ...b, chapters: b.chapters.map((c) => (c.id === ch.id ? { ...c, ...ch } : c)) } : b))
}, [])
// ---------- 生成编排 ----------
const runGenerate = (stage: string, label: string, payload: object, onAccept: (content: string, data: unknown) => void) => {
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
acceptRef.current = onAccept
doneDataRef.current = null
setGen({ stage, label, text: '', done: false })
setRunning(true)
ssePost(`/api/generate/${stage}`, payload, {
signal: ac.signal,
onDelta: (t) => setGen((g) => (g ? { ...g, text: g.text + t } : g)),
onDone: (m) => {
doneDataRef.current = m.data ?? null
setGen((g) => (g ? { ...g, done: true, text: m.content ?? g.text } : g))
},
onError: (msg) => messageApi.error(msg)
}).catch((e) => {
if ((e as Error).name !== 'AbortError') messageApi.error(String(e))
}).finally(() => setRunning(false))
}
const acceptGenerate = () => {
if (!gen) return
const { text } = gen
const handler = acceptRef.current
const data = doneDataRef.current
setGen(null)
Promise.resolve()
.then(() => handler?.(text, data))
.catch((e) => messageApi.error('采纳失败:' + (e as Error).message))
}
// 各阶段的采纳回调
const runGenerateCharacters = (requirement: string) =>
runGenerate('characters', '生成人物卡', { projectId: pid, requirement }, async (_c, data) => {
const r = await apiPost<{ inserted: number }>('/api/generate/apply/characters', { projectId: pid, items: data })
messageApi.success(`已保存 ${r.inserted} 个人物卡,可在右侧「人物」中查看修改`)
await reload()
})
const runGenerateWorld = (requirement: string) =>
runGenerate('world', '生成世界观设定', { projectId: pid, requirement }, async (_c, data) => {
const r = await apiPost<{ inserted: number }>('/api/generate/apply/world', { projectId: pid, items: data })
messageApi.success(`已保存 ${r.inserted} 条世界观设定`)
await reload()
})
const runGenerateVolumes = (requirement: string) => {
const hasContent = (bundle?.chapters ?? []).some((c) => c.content.trim())
const doRun = () =>
runGenerate('volumes', '生成分卷大纲', { projectId: pid, requirement }, async (_c, data) => {
const r = await apiPost<{ inserted: number }>('/api/generate/apply/volumes', { projectId: pid, items: data })
messageApi.success(`大纲已应用:${r.inserted} 卷。下一步请到「细纲」为每卷生成分章细纲`)
await reload()
})
if (hasContent || (bundle?.beats.length ?? 0) > 0) {
Modal.confirm({
title: '重新生成大纲将清空现有卷与细纲',
content: '已写正文的章节会保留为「未分卷」;空章节与细纲会被删除。确定继续?',
okText: '继续生成',
okButtonProps: { danger: true },
onOk: doRun
})
} else doRun()
}
const runGenerateBeats = (volumeId: number, requirement: string) => {
const volume = bundle?.volumes.find((v) => v.id === volumeId)
const doRun = () =>
runGenerate('beats', `生成《${volume?.title ?? ''}》分章细纲`, { projectId: pid, volumeId, requirement }, async (_c, data) => {
const r = await apiPost<{ inserted: number }>('/api/generate/apply/beats', { projectId: pid, volumeId, items: data })
messageApi.success(`细纲已应用:${r.inserted} 章,章节树已更新。可在「细纲」中微调后开始写正文`)
await reload()
})
const volChapters = bundle?.chapters.filter((c) => c.volume_id === volumeId) ?? []
if (volChapters.some((c) => c.content.trim())) {
Modal.confirm({
title: '重新生成细纲将删除本卷空章节',
content: '已写正文的章节会保留,但不再关联新细纲。确定继续?',
okText: '继续生成',
okButtonProps: { danger: true },
onOk: doRun
})
} else doRun()
}
const runProse = (chId: number, requirement: string) =>
runGenerate('prose', '生成本章正文', { projectId: pid, chapterId: chId, requirement }, async (content) => {
if (!content.trim()) {
messageApi.warning('生成为空')
return
}
await apiPatch(`/api/chapters/${chId}`, { content, status: 'generated' })
updateChapterLocal({ id: chId, content, status: 'generated' })
messageApi.open({ key: 'pp', type: 'loading', content: 'AI 正在更新故事圣经(摘要 / 设定 / 伏笔 / 时间线)…', duration: 0 })
try {
const r = await apiPost<PostprocessResult>(`/api/chapters/${chId}/postprocess`)
messageApi.open({
key: 'pp',
type: 'success',
content: `故事圣经已更新:新设定 +${r.new_world_facts},人物变化 +${r.character_updates},伏笔 +${r.new_foreshadows} / 回收 ${r.paid_foreshadows},时间线 +${r.timeline_events}`
})
} catch (e) {
messageApi.open({ key: 'pp', type: 'error', content: '圣经回写失败:' + (e as Error).message })
}
await reload()
})
const runRewrite = (req: RewriteReq) =>
runGenerate('rewrite', { rewrite: '重写段落', expand: '扩写段落', shrink: '缩写段落', custom: '按指令改写' }[req.mode], req, async (text) => {
const ch = await apiGet<Bundle['chapters'][number]>(`/api/chapters/${req.chapterId}`)
const next = ch.content.slice(0, req.start) + text + ch.content.slice(req.end)
await apiPatch(`/api/chapters/${req.chapterId}`, { content: next, status: 'edited' })
updateChapterLocal({ id: req.chapterId, content: next, status: 'edited' })
messageApi.success('段落已替换')
})
// ---------- 渲染 ----------
if (!bundle) {
return (
<div style={{ display: 'grid', placeItems: 'center', height: '100vh' }}>
<div style={{ color: '#999' }}>加载中…</div>
</div>
)
}
const project = bundle.project
const totalWords = bundle.chapters.reduce((s, c) => s + c.word_count, 0)
const writtenCount = bundle.chapters.filter((c) => c.content.trim()).length
const chapter = bundle.chapters.find((c) => c.id === chapterId)
return (
<Layout style={{ height: '100vh' }}>
{messageContextHolder}
<Layout.Header
style={{ background: '#fff', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', gap: 12, paddingInline: 20, height: 56, lineHeight: '56px' }}
>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/')} />
<span style={{ fontSize: 16, fontWeight: 600 }}>{project.name}</span>
{project.genre && <Tag color="purple">{project.genre}</Tag>}
<Tooltip title="编辑作品信息">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => { editForm.setFieldsValue(project); setEditOpen(true) }} />
</Tooltip>
<span style={{ color: '#999', fontSize: 13, marginLeft: 8 }}>
总字数 {totalWords.toLocaleString()} · 已写 {writtenCount}/{Math.max(bundle.chapters.length, project.target_chapter_count)} 章
</span>
<div style={{ flex: 1 }} />
<Dropdown
menu={{
items: [
{ key: 'txt', label: '导出 TXT' },
{ key: 'epub', label: '导出 EPUB' }
],
onClick: ({ key }) => window.open(`/api/export/${pid}/${key}`)
}}
>
<Button icon={<DownloadOutlined />}>导出</Button>
</Dropdown>
<Button icon={<SettingOutlined />} onClick={() => setSettingsOpen(true)}>
模型设置
</Button>
</Layout.Header>
<PipelineGuide
bundle={bundle}
running={running}
onGoto={(t) => setTab(t)}
onGenerateCharacters={() => askRequirement('characters', '生成人物卡 · 生成要求(可选)', runGenerateCharacters)}
onGenerateWorld={() => askRequirement('world', '生成世界观 · 生成要求(可选)', runGenerateWorld)}
/>
<Layout style={{ minHeight: 0 }}>
<Layout.Sider width={300} theme="light" style={{ borderRight: '1px solid #eee', overflow: 'auto' }}>
<ChapterTree
bundle={bundle}
chapterId={chapterId}
running={running}
onSelectChapter={(cid) => {
setChapterId(cid)
setTab('prose')
}}
onSelectVolume={(vid) => {
setBeatsVolumeId(vid)
setTab('beats')
}}
onChanged={() => reload()}
/>
</Layout.Sider>
<Layout.Content style={{ padding: '16px 20px 28px', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
<Segmented
block
style={{ alignSelf: 'flex-start', minWidth: 380 }}
value={tab}
onChange={(v) => setTab(v as typeof tab)}
options={[
{ label: '正文', value: 'prose' },
{ label: '大纲', value: 'outline' },
{ label: '细纲', value: 'beats' }
]}
/>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', marginTop: 12 }}>
{tab === 'prose' && (
<ChapterEditor
bundle={bundle}
chapter={chapter}
running={running}
onPatchChapter={(patch) => apiPatch<Chapter>(`/api/chapters/${patch.id}`, patch).then((c) => updateChapterLocal(c))}
onGenerateProse={(chId) => askRequirement('prose', '生成本章正文 · 生成要求(可选)', (req) => runProse(chId, req))}
onRewrite={runRewrite}
onSwitchChapter={(cid) => setChapterId(cid)}
/>
)}
{tab === 'outline' && (
<OutlineView bundle={bundle} running={running} onGenerate={() => askRequirement('volumes', '生成分卷大纲 · 生成要求(可选)', runGenerateVolumes)} onChanged={() => reload()} />
)}
{tab === 'beats' && (
<BeatsView
bundle={bundle}
volumeId={beatsVolumeId}
setVolumeId={setBeatsVolumeId}
running={running}
onGenerate={(vid) => askRequirement('beats', '生成分章细纲 · 生成要求(可选)', (req) => runGenerateBeats(vid, req))}
onChanged={() => reload()}
onWriteChapter={(cid) => {
setChapterId(cid)
setTab('prose')
}}
/>
)}
</div>
</Layout.Content>
<Layout.Sider width={380} theme="light" style={{ borderLeft: '1px solid #eee', overflow: 'auto' }}>
<div style={{ padding: '6px 12px 16px' }}>
<StoryBible bundle={bundle} onChanged={() => reload()} running={running} />
</div>
</Layout.Sider>
</Layout>
<GenerateDrawer
gen={gen}
running={running}
onAccept={acceptGenerate}
onDiscard={() => setGen(null)}
onAbort={() => abortRef.current?.abort()}
/>
<Modal
title={reqTitle}
open={reqOpen}
onCancel={() => setReqOpen(false)}
onOk={() => {
setReqOpen(false)
pendingRunRef.current?.(reqText.trim())
}}
okText="开始生成"
okButtonProps={{ disabled: running }}
width={540}
>
<div style={{ color: '#888', fontSize: 13, marginBottom: 8 }}>
可选:向 AI 下达本次生成的要求与限制(数量、情节走向、禁用元素、文风偏好等),它会叠加在项目信息之上严格执行;留空则按默认策略生成。生成后请逐项审核,不满意可带着新要求重新生成。
</div>
<div style={{ paddingBottom: 26 }}>
<Input.TextArea
rows={4}
value={reqText}
onChange={(e) => setReqText(e.target.value)}
maxLength={3000}
showCount
placeholder="例:主角性格更内敛;只要都市异能不要修仙元素;反派必须有值得同情的动机……"
/>
</div>
</Modal>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<Modal
title="编辑作品信息"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={async () => {
try {
const values = await editForm.validateFields()
await apiPatch(`/api/projects/${pid}`, values)
setEditOpen(false)
await reload()
} catch (e) {
if ((e as { errorFields?: unknown }).errorFields === undefined) messageApi.error((e as Error).message)
throw e
}
}}
width={640}
>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="作品名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="genre" label="题材">
<Select
allowClear
options={['玄幻', '仙侠', '都市', '科幻', '悬疑', '历史', '言情', '奇幻', '武侠', '游戏', '其他'].map((g) => ({ value: g, label: g }))}
/>
</Form.Item>
<Form.Item name="premise" label="故事创意">
<Input.TextArea rows={5} />
</Form.Item>
<Form.Item name="style_notes" label="文风要求">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="style_sample" label="文风示例">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="narrative_pov" label="叙事视角(正文生成时严格执行视角纪律)">
<Select
allowClear
placeholder="选择或留空由 AI 酌情处理"
options={['第三人称限知', '第三人称全知', '第一人称', '多视角轮转'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name="taboos" label="禁用内容(严禁出现的元素 / 题材 / 桥段)">
<Input.TextArea rows={2} placeholder="例:不要重生穿越;不要系统流;不出现真实政治人物……" />
</Form.Item>
<Form.Item label="目标规模">
<Input.Group compact>
<Form.Item name="target_chapter_count" noStyle>
<InputNumber min={1} max={2000} addonAfter="章" style={{ width: 160 }} />
</Form.Item>
<Form.Item name="target_chapter_words" noStyle>
<InputNumber min={200} max={20000} addonAfter="字/章" style={{ width: 180, marginLeft: 8 }} />
</Form.Item>
</Input.Group>
</Form.Item>
</Form>
</Modal>
</Layout>
)
}