AI 小说生成工作台:分层流水线 + 故事圣经(一期完整实现)
- 后端:Node/Express + node:sqlite,人物/世界观/分卷/细纲/正文 8 阶段生成流水线(SSE 流式 + zod 校验 + 重试) - 前端:React 18 + Vite + antd 三栏工作台:流水线引导、章节树、编辑器(快切章节/段落改写)、故事圣经面板 - 写作工艺注入:人物声口/内在冲突/秘密,细纲 POV/情绪曲线/功能/张力,正文专业规则 - 自动回写故事圣经(摘要/设定/人物变化/伏笔/时间线),TXT/EPUB 导出,token 成本日志
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智能小说工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit -p tsconfig.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.22.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:5175'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user