@waterwx/dsh-novel-forge 1.2.1-alpha → 1.3.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/lib/client.js +2470 -858
- package/lib/index.js +2155 -388
- package/lib/types/action-guard.d.ts +1 -1
- package/lib/types/assets.d.ts +7 -7
- package/lib/types/client/api.d.ts +31 -1
- package/lib/types/client/llmLive.d.ts +12 -14
- package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/TensionView.d.ts +14 -0
- package/lib/types/client/panel/TimelineView.d.ts +14 -0
- package/lib/types/engine.d.ts +40 -2
- package/lib/types/index.d.ts +4 -0
- package/lib/types/llm-live.d.ts +65 -34
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-sources.d.ts +1 -1
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +2 -2
- package/lib/types/protocol.d.ts +283 -3
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +1 -1
- package/lib/types/tension.d.ts +65 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +3 -2
- package/src/action-guard.ts +1 -1
- package/src/assets.ts +300 -268
- package/src/assistant.ts +45 -29
- package/src/client/api.ts +76 -2
- package/src/client/llmLive.ts +26 -16
- package/src/client/panel/AssetsTab.tsx +2 -2
- package/src/client/panel/LiveFeedLog.tsx +204 -67
- package/src/client/panel/ModelManager.tsx +2 -2
- package/src/client/panel/NovelPanel.tsx +127 -13
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/SettingsView.tsx +35 -0
- package/src/client/panel/TensionView.tsx +196 -0
- package/src/client/panel/TimelineView.tsx +192 -0
- package/src/engine.ts +549 -108
- package/src/index.ts +12 -0
- package/src/llm-live.ts +260 -68
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-sources.ts +173 -173
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +8 -8
- package/src/protocol.ts +314 -3
- package/src/routes.ts +325 -6
- package/src/run.ts +4 -0
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -189
- package/src/tension.ts +240 -0
- package/src/timeline.ts +159 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 故事时间线:把逐章抽取的「时间点 / 地点 / 在场角色 / 事件」按章节顺序排开,
|
|
3
|
+
* 带规则初筛 + AI 复核的矛盾清单,并支持就地修正(手改后标记为 manual)。
|
|
4
|
+
*
|
|
5
|
+
* 与「编年录」的分工:编年录记事实状态,这里记**顺序**——专门抓时间倒流、
|
|
6
|
+
* 地点瞬移、跨度不合理这类长篇最容易崩的地方。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
10
|
+
import type { NovelApi } from '../api.ts'
|
|
11
|
+
import type { TimelineEvent, TimelineIssue } from '../../protocol.ts'
|
|
12
|
+
import css from './panel.module.css'
|
|
13
|
+
|
|
14
|
+
type Severity = TimelineIssue['severity']
|
|
15
|
+
|
|
16
|
+
const SEVERITY_LABEL: Record<Severity, string> = { high: '高', medium: '中', low: '低' }
|
|
17
|
+
|
|
18
|
+
export interface TimelineViewProps {
|
|
19
|
+
api: NovelApi
|
|
20
|
+
/** 章节号列表(用于「抽取本章」下拉)。 */
|
|
21
|
+
chapters: number[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function TimelineView({ api, chapters }: TimelineViewProps): JSX.Element {
|
|
25
|
+
const [events, setEvents] = useState<TimelineEvent[]>([])
|
|
26
|
+
const [issues, setIssues] = useState<TimelineIssue[]>([])
|
|
27
|
+
const [busy, setBusy] = useState(false)
|
|
28
|
+
const [error, setError] = useState('')
|
|
29
|
+
const [notice, setNotice] = useState('')
|
|
30
|
+
const [extractNo, setExtractNo] = useState<number | ''>(chapters[chapters.length - 1] ?? '')
|
|
31
|
+
const [editing, setEditing] = useState<TimelineEvent | null>(null)
|
|
32
|
+
|
|
33
|
+
const refresh = useCallback(async (showError = true): Promise<void> => {
|
|
34
|
+
try {
|
|
35
|
+
const r = await api.timeline()
|
|
36
|
+
setEvents(r.events)
|
|
37
|
+
setIssues(r.issues ?? [])
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (showError) setError((err as Error).message)
|
|
40
|
+
}
|
|
41
|
+
}, [api])
|
|
42
|
+
|
|
43
|
+
useEffect(() => { void refresh(false) }, [refresh])
|
|
44
|
+
|
|
45
|
+
const grouped = useMemo(() => {
|
|
46
|
+
const map = new Map<number, TimelineEvent[]>()
|
|
47
|
+
for (const e of events) {
|
|
48
|
+
const list = map.get(e.chapterNo) ?? []
|
|
49
|
+
list.push(e)
|
|
50
|
+
map.set(e.chapterNo, list)
|
|
51
|
+
}
|
|
52
|
+
return [...map.entries()].sort((a, b) => a[0] - b[0])
|
|
53
|
+
}, [events])
|
|
54
|
+
|
|
55
|
+
const run = useCallback(async (label: string, fn: () => Promise<void>): Promise<void> => {
|
|
56
|
+
setBusy(true); setError(''); setNotice('')
|
|
57
|
+
try {
|
|
58
|
+
await fn()
|
|
59
|
+
setNotice(label)
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setError((err as Error).message)
|
|
62
|
+
} finally {
|
|
63
|
+
setBusy(false)
|
|
64
|
+
}
|
|
65
|
+
}, [])
|
|
66
|
+
|
|
67
|
+
const handleExtract = (): void => {
|
|
68
|
+
if (extractNo === '') return
|
|
69
|
+
void run(`已抽取第 ${extractNo} 章时间线`, async () => {
|
|
70
|
+
await api.timelineExtract(Number(extractNo))
|
|
71
|
+
await refresh()
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handleCheck = (): void => {
|
|
76
|
+
void run('时间线检查完成', async () => {
|
|
77
|
+
const r = await api.timelineCheck()
|
|
78
|
+
setIssues(r.issues ?? [])
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const handleSaveEdit = (): void => {
|
|
83
|
+
if (editing === null) return
|
|
84
|
+
void run('已保存修改', async () => {
|
|
85
|
+
await api.timelineUpdate({ ...editing, source: 'manual' })
|
|
86
|
+
setEditing(null)
|
|
87
|
+
await refresh()
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handleRemove = (id: string): void => {
|
|
92
|
+
void run('已删除该事件', async () => {
|
|
93
|
+
await api.timelineRemove(id)
|
|
94
|
+
await refresh()
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
|
|
100
|
+
<div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
|
|
101
|
+
<span className={css.cardTitle}>故事时间线</span>
|
|
102
|
+
<span className={css.meta}>{events.length} 条事件 · {grouped.length} 章</span>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'center' }}>
|
|
106
|
+
<select
|
|
107
|
+
className={css.input}
|
|
108
|
+
value={extractNo}
|
|
109
|
+
onChange={e => setExtractNo(e.target.value === '' ? '' : Number(e.target.value))}
|
|
110
|
+
title="选择要抽取时间线的章节"
|
|
111
|
+
>
|
|
112
|
+
<option value="">选择章节…</option>
|
|
113
|
+
{chapters.map(no => <option key={no} value={no}>第 {no} 章</option>)}
|
|
114
|
+
</select>
|
|
115
|
+
<button type="button" className={css.button} disabled={busy || extractNo === ''} onClick={handleExtract}>抽取本章时间线</button>
|
|
116
|
+
<button type="button" className={css.button} disabled={busy || events.length === 0} onClick={handleCheck}>检查时间线矛盾</button>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-text-12)' }}>{notice}</span>}
|
|
120
|
+
{error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-text-12)' }}>{error}</span>}
|
|
121
|
+
|
|
122
|
+
{issues.length > 0 && (
|
|
123
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8, background: 'var(--nf-bg-inset)' }}>
|
|
124
|
+
<b style={{ fontSize: 'var(--nf-fs-12)' }}>发现 {issues.length} 处时间线问题</b>
|
|
125
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
|
|
126
|
+
{issues.map((issue, i) => (
|
|
127
|
+
<div key={i} style={{ fontSize: 'var(--nf-fs-12)' }}>
|
|
128
|
+
<span className={css.lfPill} data-phase={issue.severity === 'high' ? 'failed' : 'requesting'}>{SEVERITY_LABEL[issue.severity]}</span>
|
|
129
|
+
<span style={{ marginLeft: 6 }}>{issue.chapters.length > 0 ? `第 ${issue.chapters.join('、')} 章:` : ''}{issue.item}</span>
|
|
130
|
+
{issue.suggestion !== '' && <div className={css.meta} style={{ marginLeft: 6 }}>建议:{issue.suggestion}</div>}
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', maxHeight: '52vh' }}>
|
|
138
|
+
{events.length === 0 && <span className={css.meta}>还没有时间线事件。生成章节后会自动抽取,也可以在上面选一章手工抽取。</span>}
|
|
139
|
+
{grouped.map(([no, list]) => (
|
|
140
|
+
<div key={no}>
|
|
141
|
+
<div className={css.meta} style={{ marginBottom: 4 }}>第 {no} 章 · {list.length} 条</div>
|
|
142
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
143
|
+
{list.map(e => (
|
|
144
|
+
<div key={e.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px', fontSize: 'var(--nf-fs-12)' }}>
|
|
145
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
|
146
|
+
<b>{e.time !== '' ? e.time : '(未标注时间)'}</b>
|
|
147
|
+
{e.place !== '' && <span className={css.meta}>@{e.place}</span>}
|
|
148
|
+
{e.characters.length > 0 && <span className={css.meta}>{e.characters.join('、')}</span>}
|
|
149
|
+
{e.source === 'manual' && <span className={css.meta}>(手改)</span>}
|
|
150
|
+
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
|
151
|
+
<button type="button" className={css.iconButton} title="修正这条事件" onClick={() => setEditing({ ...e })}>✎</button>
|
|
152
|
+
<button type="button" className={css.iconButton} title="删除这条事件" onClick={() => handleRemove(e.id)}>×</button>
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div style={{ marginTop: 2 }}>{e.event}</div>
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{editing !== null && (
|
|
164
|
+
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => setEditing(null)}>
|
|
165
|
+
<div onClick={ev => ev.stopPropagation()} style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 10, width: 'min(560px, 94vw)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
166
|
+
<b style={{ fontSize: 'var(--nf-fs-13)' }}>修正时间线事件 · 第 {editing.chapterNo} 章</b>
|
|
167
|
+
<label className={css.field}>
|
|
168
|
+
<span className={css.fieldLabel}>故事内时间</span>
|
|
169
|
+
<input className={css.input} value={editing.time} onChange={e => setEditing({ ...editing, time: e.target.value })} placeholder="如:第三日黄昏 / 入宗三个月后" />
|
|
170
|
+
</label>
|
|
171
|
+
<label className={css.field}>
|
|
172
|
+
<span className={css.fieldLabel}>地点</span>
|
|
173
|
+
<input className={css.input} value={editing.place} onChange={e => setEditing({ ...editing, place: e.target.value })} />
|
|
174
|
+
</label>
|
|
175
|
+
<label className={css.field}>
|
|
176
|
+
<span className={css.fieldLabel}>在场角色(顿号或逗号分隔)</span>
|
|
177
|
+
<input className={css.input} value={editing.characters.join('、')} onChange={e => setEditing({ ...editing, characters: e.target.value.split(/[、,,\s]+/).filter(Boolean) })} />
|
|
178
|
+
</label>
|
|
179
|
+
<label className={css.field}>
|
|
180
|
+
<span className={css.fieldLabel}>事件</span>
|
|
181
|
+
<textarea className={css.textarea} rows={3} value={editing.event} onChange={e => setEditing({ ...editing, event: e.target.value })} />
|
|
182
|
+
</label>
|
|
183
|
+
<div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
|
|
184
|
+
<button type="button" className={css.button} onClick={() => setEditing(null)}>取消</button>
|
|
185
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={handleSaveEdit}>保存</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|