@waterwx/dsh-novel-forge 1.0.0 → 1.1.0

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.
@@ -1,6 +1,7 @@
1
1
  /**
2
- * AI 助手页签:与 AI 编辑对话讨论剧情,助手可通过动作指令直接修改
3
- * 大纲 / 设定圣经 / 章节。流式渲染回复,工具调用以事件行展示。
2
+ * AI 助手(编辑老师):与 AI 编辑对话讨论剧情,助手通过动作指令直接修改
3
+ * 大纲 / 设定圣经 / 章节。流式渲染回复;工具调用显示为步骤卡片(中文名 +
4
+ * 状态 + 耗时),顶部状态条展示当前在做什么;支持清空对话记录。
4
5
  */
5
6
  import { useCallback, useEffect, useRef, useState } from 'react'
6
7
  import type { NovelApi } from '../api.ts'
@@ -8,13 +9,35 @@ import { tt } from './helpers.ts'
8
9
  import type { AssistantFrame, AssistantMessage } from '../../protocol.ts'
9
10
  import css from './panel.module.css'
10
11
 
12
+ /** 工具中文名 + 图标映射(作者看得懂)。 */
13
+ const TOOL_LABELS: Record<string, { icon: string; label: string }> = {
14
+ book_overview: { icon: '📖', label: '读取全书上下文' },
15
+ impact_analysis: { icon: '🔗', label: '影响分析' },
16
+ outline_text: { icon: '📄', label: '读取大纲' },
17
+ outline_replace: { icon: '📝', label: '修改大纲' },
18
+ bible_set_rule: { icon: '📖', label: '修改设定规则' },
19
+ bible_set_redline: { icon: '🚫', label: '修改写作红线' },
20
+ chapter_text: { icon: '📄', label: '读取章节' },
21
+ chapter_rewrite: { icon: '✏️', label: '修订章节' },
22
+ chapter_generate: { icon: '✨', label: '生成章节' },
23
+ chapter_review: { icon: '🔍', label: 'AI 审稿' },
24
+ foreshadow_add: { icon: '🪤', label: '新增伏笔' },
25
+ foreshadow_update: { icon: '🪤', label: '更新伏笔' },
26
+ export_txt: { icon: '📦', label: '导出 TXT' },
27
+ assets_status: { icon: '🎨', label: '查看写作资产' },
28
+ assets_set_genre: { icon: '🏷️', label: '设置题材' },
29
+ assets_set_progression: { icon: '📈', label: '设置推进模式' },
30
+ assets_add_rule: { icon: '🚫', label: '新增反AI规则' },
31
+ error: { icon: '⚠️', label: '出错了' },
32
+ }
33
+
11
34
  /** One chat bubble (either side). */
12
35
  interface ChatLine {
13
36
  id: number
14
37
  role: 'user' | 'assistant'
15
38
  text: string
16
- /** Tool events interleaved with the assistant reply. */
17
- tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string }>
39
+ /** Tool steps interleaved with the assistant reply. */
40
+ tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string; startedAt?: number; elapsedMs?: number }>
18
41
  /** Live output while a tool runs (generated text streamed into the bubble). */
19
42
  live?: string
20
43
  }
@@ -30,9 +53,25 @@ export function AssistantTab({ api }: AssistantTabProps) {
30
53
  const [input, setInput] = useState('')
31
54
  const [busy, setBusy] = useState(false)
32
55
  const [error, setError] = useState('')
56
+ const [notice, setNotice] = useState('')
57
+ /** 思考耗时(秒,busy 时每秒刷新)。 */
58
+ const [thinkSeconds, setThinkSeconds] = useState(0)
33
59
  const idRef = useRef(0)
34
60
  const scrollRef = useRef<HTMLDivElement | null>(null)
35
61
 
62
+ /** busy 计时:显示"已思考 Xs",让作者知道在干活。 */
63
+ useEffect(() => {
64
+ if (!busy) {
65
+ setThinkSeconds(0)
66
+ return
67
+ }
68
+ const started = Date.now()
69
+ const timer = window.setInterval(() => {
70
+ setThinkSeconds(Math.floor((Date.now() - started) / 1000))
71
+ }, 1000)
72
+ return () => { window.clearInterval(timer) }
73
+ }, [busy])
74
+
36
75
  /** Append a bubble (or extend the current assistant bubble). */
37
76
  const pushLine = useCallback((line: Omit<ChatLine, 'id'>) => {
38
77
  setLines(prev => {
@@ -45,14 +84,37 @@ export function AssistantTab({ api }: AssistantTabProps) {
45
84
  })
46
85
  }, [])
47
86
 
48
- /** Push a tool event onto the current assistant bubble. */
87
+ /** Push a tool event: start 创建步骤,done/error 更新同名步骤(含耗时)。 */
49
88
  const pushTool = useCallback((tool: ChatLine['tools'][number]) => {
50
89
  setLines(prev => {
51
90
  const last = prev[prev.length - 1]
52
91
  if (last === undefined || last.role !== 'assistant') {
53
- return [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [tool] }]
92
+ return [...prev, {
93
+ id: idRef.current++,
94
+ role: 'assistant',
95
+ text: '',
96
+ tools: [{ ...tool, startedAt: tool.status === 'start' ? Date.now() : undefined }],
97
+ }]
54
98
  }
55
- return [...prev.slice(0, -1), { ...last, tools: [...last.tools, tool], live: undefined }]
99
+ const tools = [...last.tools]
100
+ if (tool.status === 'start') {
101
+ tools.push({ ...tool, startedAt: Date.now() })
102
+ } else {
103
+ // 找到同名未完成的步骤,更新状态与耗时。
104
+ for (let i = tools.length - 1; i >= 0; i--) {
105
+ const step = tools[i]!
106
+ if (step.name === tool.name && step.status === 'start') {
107
+ tools[i] = {
108
+ ...step,
109
+ status: tool.status,
110
+ detail: tool.detail,
111
+ elapsedMs: Date.now() - (step.startedAt ?? Date.now()),
112
+ }
113
+ break
114
+ }
115
+ }
116
+ }
117
+ return [...prev.slice(0, -1), { ...last, tools, live: undefined }]
56
118
  })
57
119
  }, [])
58
120
 
@@ -65,6 +127,19 @@ export function AssistantTab({ api }: AssistantTabProps) {
65
127
  })
66
128
  }, [])
67
129
 
130
+ /** 当前进行中的工具(状态条显示)。 */
131
+ const activeTool = (() => {
132
+ if (!busy) return null
133
+ for (let i = lines.length - 1; i >= 0; i--) {
134
+ const line = lines[i]!
135
+ if (line.role !== 'assistant') continue
136
+ for (let j = line.tools.length - 1; j >= 0; j--) {
137
+ if (line.tools[j]!.status === 'start') return line.tools[j]!
138
+ }
139
+ }
140
+ return null
141
+ })()
142
+
68
143
  /** Load persisted history on mount. */
69
144
  useEffect(() => {
70
145
  let cancelled = false
@@ -78,11 +153,6 @@ export function AssistantTab({ api }: AssistantTabProps) {
78
153
  restored.push({ id: idRef.current++, role: 'user', text: entry.content, tools: [] })
79
154
  } else if (entry.role === 'assistant') {
80
155
  restored.push({ id: idRef.current++, role: 'assistant', text: entry.content, tools: [] })
81
- } else if (entry.role === 'tool') {
82
- const last = restored[restored.length - 1]
83
- if (last !== undefined && last.role === 'assistant') {
84
- last.tools.push({ name: entry.tool ?? 'tool', status: 'done', detail: entry.content.slice(0, 120) })
85
- }
86
156
  }
87
157
  }
88
158
  setLines(restored)
@@ -98,12 +168,29 @@ export function AssistantTab({ api }: AssistantTabProps) {
98
168
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
99
169
  }, [lines])
100
170
 
171
+ /** 清空对话记录(服务端删除历史 + 本地清空)。 */
172
+ const handleClear = async (): Promise<void> => {
173
+ if (!window.confirm('清空全部聊天记录?此操作不可恢复(不影响大纲/设定/章节)。')) return
174
+ setBusy(true)
175
+ setError('')
176
+ try {
177
+ await api.assistantClear()
178
+ setLines([])
179
+ setNotice('对话已清空,编辑老师会重新了解项目')
180
+ } catch (err) {
181
+ setError((err as Error).message)
182
+ } finally {
183
+ setBusy(false)
184
+ }
185
+ }
186
+
101
187
  /** Send one message. */
102
188
  const handleSend = async (): Promise<void> => {
103
189
  const message = input.trim()
104
190
  if (message === '' || busy) return
105
191
  setInput('')
106
192
  setError('')
193
+ setNotice('')
107
194
  pushLine({ role: 'user', text: message, tools: [] })
108
195
  // Start an empty assistant bubble.
109
196
  setLines(prev => [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [] }])
@@ -133,9 +220,21 @@ export function AssistantTab({ api }: AssistantTabProps) {
133
220
 
134
221
  return (
135
222
  <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
136
- <span className={css.cardTitle}>{tt('tab.assistant')}</span>
223
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
224
+ <span className={css.cardTitle}>{tt('tab.assistant')}</span>
225
+ <button type="button" className={css.iconButton} title="清空聊天记录" aria-label="清空聊天记录" onClick={() => { void handleClear() }}>
226
+ 🗑️
227
+ </button>
228
+ </div>
137
229
  <span className={css.meta}>{tt('assistant.hint')}</span>
230
+ {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 12 }}>{notice}</span>}
138
231
  {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{tt('common.error')}: {error}</span>}
232
+ {/* 状态条:编辑老师正在做什么 */}
233
+ <div className={`${css.assistantStatus} ${busy ? css.assistantStatusBusy : ''}`}>
234
+ {busy
235
+ ? `🤖 编辑老师 · ${activeTool !== null ? `正在「${TOOL_LABELS[activeTool.name]?.label ?? activeTool.name}」` : `正在思考…(已 ${thinkSeconds}s)`}`
236
+ : '💬 编辑老师 · 等你开口'}
237
+ </div>
139
238
  <div
140
239
  ref={scrollRef}
141
240
  className={css.chatScroll}
@@ -148,17 +247,31 @@ export function AssistantTab({ api }: AssistantTabProps) {
148
247
  {line.live !== undefined && line.live !== '' && (
149
248
  <div className={css.toolLive}>{line.live}</div>
150
249
  )}
250
+ {/* 步骤卡片 */}
151
251
  {line.tools.length > 0 && (
152
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4, fontSize: 11 }}>
153
- {line.tools.map((tool, i) => (
154
- <span key={i} style={{ color: tool.status === 'error' ? 'var(--nf-error)' : tool.status === 'start' ? 'var(--nf-accent)' : 'var(--nf-success)' }}>
155
- {tool.status === 'start'
156
- ? tt('assistant.toolStart', { name: tool.name })
157
- : tool.status === 'done'
158
- ? tt('assistant.toolDone', { name: tool.name, detail: tool.detail ?? '' })
159
- : tt('assistant.toolError', { name: tool.name, detail: tool.detail ?? '' })}
160
- </span>
161
- ))}
252
+ <div className={css.toolSteps}>
253
+ {line.tools.map((tool, i) => {
254
+ const meta = TOOL_LABELS[tool.name] ?? { icon: '⚙️', label: tool.name }
255
+ return (
256
+ <div
257
+ key={i}
258
+ className={`${css.toolStep} ${tool.status === 'error' ? css.toolStepError : tool.status === 'start' ? css.toolStepActive : ''}`}
259
+ >
260
+ <span className={css.toolStepIcon}>{meta.icon}</span>
261
+ <span className={css.toolStepName}>{meta.label}</span>
262
+ <span className={css.toolStepStatus}>
263
+ {tool.status === 'start'
264
+ ? '⏳ 进行中'
265
+ : tool.status === 'done'
266
+ ? `✓ ${tool.elapsedMs !== undefined ? `${(tool.elapsedMs / 1000).toFixed(1)}s` : ''}`
267
+ : '✗ 失败'}
268
+ </span>
269
+ {tool.status === 'error' && tool.detail !== undefined && (
270
+ <span className={css.toolStepDetail}>{tool.detail}</span>
271
+ )}
272
+ </div>
273
+ )
274
+ })}
162
275
  </div>
163
276
  )}
164
277
  </div>
@@ -0,0 +1,189 @@
1
+ /**
2
+ * 开书向导:独立页面视图 —— 书名 + 大纲(选择 docx / 拖拽 / 粘贴),
3
+ * 实时书名识别与题材提示,开书即建项目并进入工作台。
4
+ */
5
+ import { useRef, useState } from 'react'
6
+ import type { NovelApi } from '../api.ts'
7
+ import { extractDocxTextFromBuffer } from '../docx.ts'
8
+ import css from './panel.module.css'
9
+
10
+ /** 从大纲首行推断书名(与服务端 inferBookName 一致,供实时预览)。 */
11
+ function inferBookNamePreview(outline: string): string {
12
+ const line = outline.split('\n').map(l => l.trim()).find(l => l.length > 0)
13
+ if (line === undefined) return ''
14
+ return line.replace(/^《/, '').replace(/》.*$/, '').slice(0, 40)
15
+ }
16
+
17
+ /** 简单题材识别(提示用)。 */
18
+ function guessGenre(outline: string): string | null {
19
+ const map: Array<[string, string[]]> = [
20
+ ['仙侠修真', ['仙', '修', '灵根', '元婴', '宗门', '飞升']],
21
+ ['都市', ['都市', '公司', '外卖', '职场', '总裁']],
22
+ ['玄幻', ['斗气', '魂力', '大陆', '斗罗', '神']],
23
+ ['悬疑', ['悬疑', '密室', '案件', '推理', '凶']],
24
+ ['科幻', ['机甲', '星舰', 'AI', '未来', '星际']],
25
+ ['历史', ['朝代', '皇帝', '将军', '古代', '王朝']],
26
+ ['游戏', ['游戏', '副本', '装备', '等级', '职业']],
27
+ ]
28
+ for (const [genre, keywords] of map) {
29
+ if (keywords.some(k => outline.includes(k))) return genre
30
+ }
31
+ return null
32
+ }
33
+
34
+ /** 开书向导页。 */
35
+ export function CreateBookView({
36
+ api,
37
+ onBack,
38
+ onCreated,
39
+ }: {
40
+ api: NovelApi
41
+ /** 返回书架。 */
42
+ onBack: () => void
43
+ /** 开书成功:进入新书工作台。 */
44
+ onCreated: (id: string) => void
45
+ }) {
46
+ const [name, setName] = useState('')
47
+ const [outlineText, setOutlineText] = useState('')
48
+ const [outlineName, setOutlineName] = useState('')
49
+ const [dragActive, setDragActive] = useState(false)
50
+ const [busy, setBusy] = useState(false)
51
+ const [error, setError] = useState('')
52
+ const outlineFileRef = useRef<HTMLInputElement | null>(null)
53
+
54
+ const autoName = inferBookNamePreview(outlineText)
55
+ const effectiveName = name.trim() !== '' ? name.trim() : autoName
56
+ const genre = guessGenre(outlineText)
57
+
58
+ const handlePickOutlineFile = async (file: File | undefined): Promise<void> => {
59
+ if (file === undefined) return
60
+ try {
61
+ const buffer = await file.arrayBuffer()
62
+ const text = extractDocxTextFromBuffer(buffer)
63
+ if (text.length < 50) {
64
+ setError('大纲内容过短(<50 字符),请检查文件')
65
+ return
66
+ }
67
+ setOutlineText(text)
68
+ setOutlineName(file.name)
69
+ if (name.trim() === '') setName(inferBookNamePreview(text))
70
+ setError('')
71
+ } catch (err) {
72
+ setError(`读取大纲失败:${(err as Error).message}`)
73
+ }
74
+ }
75
+
76
+ const handleCreate = async (): Promise<void> => {
77
+ if (effectiveName === '') {
78
+ setError('请填写书名,或提供大纲自动识别')
79
+ return
80
+ }
81
+ setBusy(true)
82
+ setError('')
83
+ try {
84
+ const snapshot = await api.bookCreate(effectiveName, undefined, outlineText.trim() !== '' ? outlineText.trim() : undefined)
85
+ const created = snapshot.books.find(b => b.id === snapshot.activeBookId)
86
+ if (created !== undefined) {
87
+ onCreated(created.id)
88
+ } else {
89
+ setError('开书失败:未找到新书')
90
+ }
91
+ } catch (err) {
92
+ setError((err as Error).message)
93
+ } finally {
94
+ setBusy(false)
95
+ }
96
+ }
97
+
98
+ return (
99
+ <div className={css.createBookView}>
100
+ <div className={css.createBookTop}>
101
+ <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={onBack}>
102
+ ← 书架
103
+ </button>
104
+ </div>
105
+
106
+ <div className={css.createBookCard}>
107
+ <span className={css.createBookIcon}>✒️</span>
108
+ <h2 className={css.createBookTitle}>开书向导</h2>
109
+ <span className={css.meta}>把一份大纲「编译」成一本完整的小说</span>
110
+
111
+ {error !== '' && (
112
+ <div className={css.card} style={{ borderColor: 'var(--nf-error)', padding: '8px 12px' }}>
113
+ <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{error}</span>
114
+ </div>
115
+ )}
116
+
117
+ <div className={css.field}>
118
+ <label className={css.fieldLabel}>书名</label>
119
+ <input
120
+ className={css.input}
121
+ placeholder={autoName !== '' ? `自动识别:${autoName}` : '输入书名(提供大纲后自动识别)'}
122
+ value={name}
123
+ onChange={e => { setName(e.target.value) }}
124
+ onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }}
125
+ autoFocus
126
+ />
127
+ </div>
128
+
129
+ <div className={css.field}>
130
+ <label className={css.fieldLabel}>大纲</label>
131
+ <div
132
+ className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
133
+ onClick={() => { outlineFileRef.current?.click() }}
134
+ onDragOver={e => { e.preventDefault(); setDragActive(true) }}
135
+ onDragLeave={() => { setDragActive(false) }}
136
+ onDrop={e => {
137
+ e.preventDefault()
138
+ setDragActive(false)
139
+ void handlePickOutlineFile(e.dataTransfer.files?.[0])
140
+ }}
141
+ >
142
+ <span className={css.dropzoneIcon}>📄</span>
143
+ <span>{outlineName !== '' ? `已选择:${outlineName}` : '点击选择 docx 大纲,或将文件拖到这里'}</span>
144
+ <span className={css.meta}>推荐提供大纲:开书即建立项目,书名自动识别</span>
145
+ <input
146
+ ref={outlineFileRef}
147
+ type="file"
148
+ accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
149
+ style={{ display: 'none' }}
150
+ onChange={e => {
151
+ void handlePickOutlineFile(e.target.files?.[0])
152
+ e.target.value = ''
153
+ }}
154
+ />
155
+ </div>
156
+ <textarea
157
+ className={css.textarea}
158
+ style={{ minHeight: 130 }}
159
+ placeholder="或直接粘贴大纲文本(50 字以上)…"
160
+ value={outlineText}
161
+ onChange={e => { setOutlineText(e.target.value) }}
162
+ spellCheck={false}
163
+ />
164
+ </div>
165
+
166
+ {(outlineText.trim().length > 0 || effectiveName !== '') && (
167
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
168
+ {outlineText.trim().length > 0 && <span className={css.meta}>大纲 {outlineText.length} 字</span>}
169
+ {effectiveName !== '' && <span className={css.meta}>书名:{effectiveName}</span>}
170
+ {genre !== null && <span className={css.meta}>题材:{genre}</span>}
171
+ </div>
172
+ )}
173
+
174
+ <button
175
+ type="button"
176
+ className={`${css.button} ${css.buttonPrimary}`}
177
+ style={{ width: '100%', padding: '10px 0', fontSize: 14 }}
178
+ disabled={busy || effectiveName === ''}
179
+ onClick={() => { void handleCreate() }}
180
+ >
181
+ ✨ 开书并进入工作台
182
+ </button>
183
+ <span className={css.meta} style={{ textAlign: 'center' }}>
184
+ 未提供大纲也能开书,稍后可在大纲页导入
185
+ </span>
186
+ </div>
187
+ </div>
188
+ )
189
+ }