@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.
Files changed (52) hide show
  1. package/README.md +4 -5
  2. package/lib/client.js +2470 -858
  3. package/lib/index.js +2155 -388
  4. package/lib/types/action-guard.d.ts +1 -1
  5. package/lib/types/assets.d.ts +7 -7
  6. package/lib/types/client/api.d.ts +31 -1
  7. package/lib/types/client/llmLive.d.ts +12 -14
  8. package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
  9. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  10. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  11. package/lib/types/client/panel/TensionView.d.ts +14 -0
  12. package/lib/types/client/panel/TimelineView.d.ts +14 -0
  13. package/lib/types/engine.d.ts +40 -2
  14. package/lib/types/index.d.ts +4 -0
  15. package/lib/types/llm-live.d.ts +65 -34
  16. package/lib/types/llm-retry.d.ts +38 -0
  17. package/lib/types/market-radar-sources.d.ts +1 -1
  18. package/lib/types/prompt-slots.d.ts +57 -0
  19. package/lib/types/prompting.d.ts +2 -2
  20. package/lib/types/protocol.d.ts +283 -3
  21. package/lib/types/snapshots.d.ts +46 -0
  22. package/lib/types/stage-contract.d.ts +1 -1
  23. package/lib/types/tension.d.ts +65 -0
  24. package/lib/types/timeline.d.ts +37 -0
  25. package/package.json +3 -2
  26. package/src/action-guard.ts +1 -1
  27. package/src/assets.ts +300 -268
  28. package/src/assistant.ts +45 -29
  29. package/src/client/api.ts +76 -2
  30. package/src/client/llmLive.ts +26 -16
  31. package/src/client/panel/AssetsTab.tsx +2 -2
  32. package/src/client/panel/LiveFeedLog.tsx +204 -67
  33. package/src/client/panel/ModelManager.tsx +2 -2
  34. package/src/client/panel/NovelPanel.tsx +127 -13
  35. package/src/client/panel/PromptSlotsView.tsx +97 -0
  36. package/src/client/panel/SettingsView.tsx +35 -0
  37. package/src/client/panel/TensionView.tsx +196 -0
  38. package/src/client/panel/TimelineView.tsx +192 -0
  39. package/src/engine.ts +549 -108
  40. package/src/index.ts +12 -0
  41. package/src/llm-live.ts +260 -68
  42. package/src/llm-retry.ts +77 -0
  43. package/src/market-radar-sources.ts +173 -173
  44. package/src/prompt-slots.ts +125 -0
  45. package/src/prompting.ts +8 -8
  46. package/src/protocol.ts +314 -3
  47. package/src/routes.ts +325 -6
  48. package/src/run.ts +4 -0
  49. package/src/snapshots.ts +174 -0
  50. package/src/stage-contract.ts +189 -189
  51. package/src/tension.ts +240 -0
  52. package/src/timeline.ts +159 -0
package/src/assistant.ts CHANGED
@@ -37,6 +37,9 @@ import {
37
37
  } from './engine.ts'
38
38
  import { rewriteChapterStream, countHanzi } from './engine.ts'
39
39
  import { computeBookStage, isGenericContinue, renderStageContract, splitByStage, stageAllows, type BookStage } from './stage-contract.ts'
40
+ import { beginLiveCall, endLiveCall, markFirstToken } from './llm-live.ts'
41
+ import { withModelFallback } from './llm-retry.ts'
42
+ import { createSnapshot } from './snapshots.ts'
40
43
  import { NovelActionError, classifyActionError, FailureLedger } from './action-guard.ts'
41
44
 
42
45
  /** History file name inside the output dir. */
@@ -481,6 +484,8 @@ export async function* executeAction(
481
484
  if (chapter === undefined || draft === undefined || draft === '') {
482
485
  throw new NovelActionError('contract', `章节 ${no} 修订后没有产出草稿`)
483
486
  }
487
+ // 覆盖前留存历史版本(助手直接修订会就地覆盖正文)。
488
+ if (chapter.file !== undefined && chapter.file !== '') createSnapshot(outputDir, chapter, '助手修订前')
484
489
  const fileName = chapterFileName(chapter)
485
490
  mkdirSync(outputDir, { recursive: true })
486
491
  writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
@@ -806,37 +811,48 @@ async function chatOnce(
806
811
  messages[messages.length - 1] = { ...last, content: newBlocks }
807
812
  }
808
813
  }
809
- const request: GenerateOptions = {
810
- provider: config.provider,
811
- model: config.model,
812
- messages,
813
- system,
814
- // v4-flash 推理模型:reasoning channel 占预算,给足避免回复被截断。
815
- maxTokens: Math.max(config.maxTokens, 16000),
816
- temperature: 0.7,
817
- }
818
- const assembler = new BlockAssembler()
819
- for await (const chunk of ctx.llm.stream(request)) {
820
- assembler.push(chunk)
821
- }
822
- const finish = assembler.finish
823
- if (finish.kind === 'error' || finish.kind === 'aborted') {
824
- throw new Error(`助手调用失败(${finish.kind}): ${finish.failure.message}`)
825
- }
826
- const blocks = assembler.blocks()
827
- const textBlocks = blocks
828
- .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
829
- .map(block => block.text)
830
- let text = textBlocks.join('\n').trim()
831
- if (text === '') {
832
- const reasoning = blocks
833
- .filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
814
+ /** 用指定模型跑一次(含实况打点与 token 记账)。 */
815
+ const runOnce = async (model: string): Promise<string> => {
816
+ const request: GenerateOptions = {
817
+ provider: config.provider,
818
+ model,
819
+ messages,
820
+ system,
821
+ // 推理模型的 reasoning channel 占预算,给足避免回复被截断。
822
+ maxTokens: Math.max(config.maxTokens, 16000),
823
+ temperature: 0.7,
824
+ }
825
+ // 实况打点:助手对话同样计入用量账本,并能查看实际发送的 Prompt。
826
+ const live = beginLiveCall({ label: 'AI 编辑对话', model, system, user: messages.map(m => JSON.stringify(m.content)).join(' / ').slice(0, 8000) })
827
+ const assembler = new BlockAssembler()
828
+ for await (const chunk of ctx.llm.stream(request)) {
829
+ if (chunk.type === 'text-delta') markFirstToken(live)
830
+ assembler.push(chunk)
831
+ }
832
+ const finish = assembler.finish
833
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
834
+ const message = `助手调用失败(${finish.kind}): ${finish.failure.message}`
835
+ endLiveCall(live, assembler, { phase: 'failed', error: message })
836
+ throw new Error(message)
837
+ }
838
+ const blocks = assembler.blocks()
839
+ const textBlocks = blocks
840
+ .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
834
841
  .map(block => block.text)
835
- .join('\n')
836
- .trim()
837
- if (reasoning !== '') text = reasoning
842
+ let text = textBlocks.join('\n').trim()
843
+ if (text === '') {
844
+ const reasoning = blocks
845
+ .filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
846
+ .map(block => block.text)
847
+ .join('\n')
848
+ .trim()
849
+ if (reasoning !== '') text = reasoning
850
+ }
851
+ endLiveCall(live, assembler, { chars: text.length, preview: text.slice(0, 320), phase: text === '' ? 'failed' : 'completed' })
852
+ return text
838
853
  }
839
- return text
854
+ // 主模型失败(网络/限流/额度类)且配置了备用模型时,自动换模型重试一次。
855
+ return withModelFallback({ model: config.model, fallbackModel: config.fallbackModel }, runOnce)
840
856
  }
841
857
 
842
858
  /** Run one user turn. Yields stream frames; persists history. */
package/src/client/api.ts CHANGED
@@ -152,8 +152,8 @@ export class NovelApi {
152
152
  return postJson<ForeshadowResponse>(NOVEL_API.foreshadow, req)
153
153
  }
154
154
 
155
- async exportBook(format: 'txt' | 'md'): Promise<ExportResponse> {
156
- return postJson<ExportResponse>(NOVEL_API.exportBook, { format })
155
+ async exportBook(format: 'txt' | 'md' | 'json', scope: import('../protocol.ts').ExportScope = 'book'): Promise<ExportResponse> {
156
+ return postJson<ExportResponse>(NOVEL_API.exportBook, { format, scope })
157
157
  }
158
158
 
159
159
  async chapter(no: number): Promise<ChapterResponse> {
@@ -579,6 +579,80 @@ export class NovelApi {
579
579
  }
580
580
  }
581
581
 
582
+ /** 张力曲线:数据 + 规则核对。 */
583
+ async tension(): Promise<import('../protocol.ts').TensionResponse> {
584
+ const response = await fetch(withBookId(NOVEL_API.tension))
585
+ return readJson<import('../protocol.ts').TensionResponse>(response)
586
+ }
587
+
588
+ /** 设置张力曲线预设。 */
589
+ async tensionPreset(preset: import('../protocol.ts').TensionCurvePreset): Promise<void> {
590
+ await postJson(NOVEL_API.tension, { op: 'preset', preset })
591
+ }
592
+
593
+ /** 设置/清除某一章的目标张力。 */
594
+ async tensionSet(chapterNo: number, tension?: number): Promise<void> {
595
+ await postJson(NOVEL_API.tension, { op: 'set', chapterNo, tension })
596
+ }
597
+
598
+ /** 提示词槽位:列表。 */
599
+ async promptSlots(): Promise<import('../protocol.ts').PromptSlotsResponse> {
600
+ const response = await fetch(withBookId(NOVEL_API.promptSlots))
601
+ return readJson<import('../protocol.ts').PromptSlotsResponse>(response)
602
+ }
603
+
604
+ /** 写入一个提示词槽位(空串 = 清空)。 */
605
+ async promptSlotSet(id: import('../protocol.ts').PromptSlotId, value: string): Promise<void> {
606
+ await postJson(NOVEL_API.promptSlots, { id, value })
607
+ }
608
+
609
+ /** 故事时间线:列表(含规则初筛问题)。 */
610
+ async timeline(): Promise<import('../protocol.ts').TimelineResponse> {
611
+ const response = await fetch(withBookId(NOVEL_API.timeline))
612
+ return readJson<import('../protocol.ts').TimelineResponse>(response)
613
+ }
614
+
615
+ /** 抽取某一章的时间线事件。 */
616
+ async timelineExtract(chapterNo: number): Promise<{ events: import('../protocol.ts').TimelineEvent[] }> {
617
+ return postJson<{ events: import('../protocol.ts').TimelineEvent[] }>(NOVEL_API.timeline, { op: 'extract', chapterNo })
618
+ }
619
+
620
+ /** 检查时间线矛盾(规则初筛 + AI 复核)。 */
621
+ async timelineCheck(): Promise<import('../protocol.ts').TimelineResponse> {
622
+ return postJson<import('../protocol.ts').TimelineResponse>(NOVEL_API.timeline, { op: 'check' })
623
+ }
624
+
625
+ /** 手工修正一条时间线事件。 */
626
+ async timelineUpdate(event: import('../protocol.ts').TimelineEvent): Promise<void> {
627
+ await postJson(NOVEL_API.timeline, { op: 'update', event })
628
+ }
629
+
630
+ /** 删除一条时间线事件。 */
631
+ async timelineRemove(id: string): Promise<void> {
632
+ await postJson(NOVEL_API.timeline, { op: 'remove', id })
633
+ }
634
+
635
+ /** 章节历史版本列表。 */
636
+ async snapshots(): Promise<import('../protocol.ts').SnapshotResponse> {
637
+ const response = await fetch(withBookId(NOVEL_API.snapshot))
638
+ return readJson<import('../protocol.ts').SnapshotResponse>(response)
639
+ }
640
+
641
+ /** 回滚到某个历史版本。 */
642
+ async snapshotRestore(id: string): Promise<void> {
643
+ await postJson(NOVEL_API.snapshot, { op: 'restore', id })
644
+ }
645
+
646
+ /** 给某章手工存档。 */
647
+ async snapshotCreate(chapterNo: number): Promise<void> {
648
+ await postJson(NOVEL_API.snapshot, { op: 'create', chapterNo })
649
+ }
650
+
651
+ /** 删除一个历史版本。 */
652
+ async snapshotRemove(id: string): Promise<void> {
653
+ await postJson(NOVEL_API.snapshot, { op: 'remove', id })
654
+ }
655
+
582
656
  /** Load the persisted assistant conversation. */
583
657
  async assistantHistory(): Promise<import('../protocol.ts').AssistantMessage[]> {
584
658
  const response = await fetch(withBookId(NOVEL_API.assistantHistory))
@@ -4,22 +4,10 @@
4
4
  */
5
5
 
6
6
  import { useEffect, useMemo, useState } from 'react'
7
- import { NOVEL_API } from '../protocol.ts'
7
+ import { NOVEL_API, type LlmLiveFrame, type LlmTokenUsage } from '../protocol.ts'
8
8
 
9
- export interface LlmLiveFrame {
10
- type: 'session_started' | 'output_delta' | 'reasoning_delta' | 'phase_changed' | 'session_completed'
11
- sessionId: string
12
- label?: string
13
- model?: string
14
- at: string
15
- content?: string
16
- totalChars?: number
17
- totalReasoningChars?: number
18
- phase?: 'requesting' | 'streaming' | 'completed' | 'failed'
19
- phaseMessage?: string
20
- preview?: string
21
- error?: string
22
- }
9
+ // 帧结构定义在 protocol.ts(宿主与浏览器共用同一份线上形状),此处只再导出。
10
+ export type { LlmLiveFrame }
23
11
 
24
12
  export interface LlmLiveSession {
25
13
  sessionId: string
@@ -31,6 +19,16 @@ export interface LlmLiveSession {
31
19
  totalChars: number
32
20
  startedAt: string
33
21
  updatedAt: string
22
+ /** 本次调用的 token 用量(供应商未上报时缺省)。 */
23
+ usage?: LlmTokenUsage
24
+ /** 本次调用耗时(毫秒)。 */
25
+ elapsedMs?: number
26
+ /** 首字耗时(毫秒)。 */
27
+ firstTokenMs?: number
28
+ /** 是否保存了可查看的 Prompt。 */
29
+ hasPrompt?: boolean
30
+ /** Prompt 字符数(system + user)。 */
31
+ promptChars?: number
34
32
  }
35
33
 
36
34
  const MAX_PREVIEW_CHARS = 20_000
@@ -50,6 +48,8 @@ function applyFrame(current: Record<string, LlmLiveSession>, frame: LlmLiveFrame
50
48
  totalChars: 0,
51
49
  startedAt: frame.at,
52
50
  updatedAt: frame.at,
51
+ hasPrompt: frame.hasPrompt,
52
+ promptChars: frame.promptChars,
53
53
  }
54
54
  next[id] = session
55
55
  return next
@@ -66,7 +66,17 @@ function applyFrame(current: Record<string, LlmLiveSession>, frame: LlmLiveFrame
66
66
  session = { ...session, phase: frame.phase ?? session.phase, phaseMessage: frame.phaseMessage ?? session.phaseMessage, updatedAt: frame.at }
67
67
  next[id] = session
68
68
  } else if (frame.type === 'session_completed') {
69
- session = { ...session, phase: frame.phase ?? 'completed', phaseMessage: frame.phase === 'failed' ? (frame.error ?? '调用失败') : '模型结果已准备完成', totalChars: frame.totalChars ?? session.totalChars, preview: session.preview || (frame.preview ?? ''), updatedAt: frame.at }
69
+ session = {
70
+ ...session,
71
+ phase: frame.phase ?? 'completed',
72
+ phaseMessage: frame.phase === 'failed' ? (frame.error ?? '调用失败') : '模型结果已准备完成',
73
+ totalChars: frame.totalChars ?? session.totalChars,
74
+ preview: session.preview || (frame.preview ?? ''),
75
+ updatedAt: frame.at,
76
+ usage: frame.usage ?? session.usage,
77
+ elapsedMs: frame.elapsedMs ?? session.elapsedMs,
78
+ firstTokenMs: frame.firstTokenMs ?? session.firstTokenMs,
79
+ }
70
80
  next[id] = session
71
81
  }
72
82
  return next
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 写作资产页签:题材基底库 / 推进模式库 / 反 AI 规则 / 写法引擎。
3
- * 学习自 AI-Novel-Writing-Assistant 的四大资产模块,注入到生成与审稿提示词中。
3
+ * 四大资产模块(题材 / 推进模式 / 反 AI 规则 / 叙事风格),注入到生成与审稿提示词中。
4
4
  */
5
5
  import { useCallback, useEffect, useState } from 'react'
6
6
  import type { NovelApi } from '../api.ts'
@@ -294,7 +294,7 @@ export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
294
294
  {assetTab === 'templates' && (
295
295
  <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
296
296
  <span className={css.cardTitle}>笔法帖</span>
297
- <span className={css.meta}>从内置 8 套叙事风格模板中一键选用(来自 AI-Novel-Writing-Assistant 写法引擎),无需样本文本;绑定后生成与润色都遵循该风格。</span>
297
+ <span className={css.meta}>从内置 8 套叙事风格模板中一键选用,无需样本文本;绑定后生成与润色都遵循该风格。</span>
298
298
  <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', flex: 1, minHeight: 0, overflowY: 'auto' }}>
299
299
  {data.styleTemplates.map(template => {
300
300
  const bound = assets.styleAssets.some(s => s.name === template.name)
@@ -1,67 +1,204 @@
1
- /**
2
- * 「实时调用 / LIVE」—— 内嵌到 AI 进度窗的下半区(详情层),不再独立浮窗。
3
- * 连接 /llm-live/stream,按 LLM 调用分组展示模型实时输出。
4
- *
5
- * 状态色全部走 panel.module.css 的 .lfPill / .lfDot(主题令牌 + --nf-streaming),
6
- * 不再内联硬编码色;布局性 inline style 保留(均为令牌引用或动态值)。
7
- */
8
-
9
- import { useMemo, useState } from 'react'
10
- import { useLlmLiveFeed, type LlmLiveSession } from '../llmLive.ts'
11
- import css from './panel.module.css'
12
-
13
- function PhasePill({ s }: { s: LlmLiveSession }): JSX.Element {
14
- return (
15
- <span className={css.lfPill} data-phase={s.phase}>
16
- {s.phase === 'requesting' ? '请求中' : s.phase === 'streaming' ? '生成中' : s.phase === 'failed' ? '失败' : '完成'}
17
- </span>
18
- )
19
- }
20
-
21
- function SessionCard({ s, brief }: { s: LlmLiveSession; brief: boolean }): JSX.Element {
22
- const active = s.phase === 'requesting' || s.phase === 'streaming'
23
- const dotState = active ? 'streaming' : s.phase
24
- return (
25
- <div style={{ borderBottom: '1px solid var(--nf-border)', padding: '7px 6px' }}>
26
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
27
- <span className={css.lfDot} data-state={dotState} />
28
- <b style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.label}</b>
29
- {s.model !== undefined && <span style={{ color: 'var(--nf-text-2)', fontSize: 10, maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.model}</span>}
30
- <PhasePill s={s} />
31
- </div>
32
- <div style={{ fontSize: 10, color: 'var(--nf-text-2)', marginTop: 2, display: 'flex', gap: 8 }}>
33
- <span>{s.totalChars} 字符</span>
34
- {s.phaseMessage !== '' && <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.phaseMessage}</span>}
35
- </div>
36
- {!brief && s.preview !== '' && (
37
- <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, lineHeight: 1.5, color: 'var(--nf-text)', backgroundColor: 'var(--nf-bg-inset)', borderRadius: 8, padding: 6, marginTop: 6, marginBottom: 0 }}>{s.preview}</pre>
38
- )}
39
- </div>
40
- )
41
- }
42
-
43
- export default function LiveFeedLog(): JSX.Element {
44
- const [brief, setBrief] = useState(true)
45
- const [cleared, setCleared] = useState(false)
46
- const { connected, sessions } = useLlmLiveFeed(true)
47
- const active = sessions.filter(s => s.phase === 'requesting' || s.phase === 'streaming').length
48
- const shown = useMemo(() => (cleared ? sessions.slice(-8) : sessions), [sessions, cleared])
49
- return (
50
- <div style={{ borderTop: '1px solid var(--nf-border)', marginTop: 8, paddingTop: 6, flex: '1 1 auto', minHeight: 140, display: 'flex', flexDirection: 'column' }}>
51
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '2px 4px' }}>
52
- <span className={css.lfDot} data-size="lg" data-state={connected ? 'ok' : 'idle'} />
53
- <b style={{ flex: 1 }}>实时调用 / LIVE</b>
54
- <span className={css.lfPill} data-phase={active > 0 ? 'streaming' : 'requesting'}>
55
- {active > 0 ? `${active} 项进行中` : connected ? '等待生成' : '正在连接'}
56
- </span>
57
- <button type="button" title={brief ? '详细' : '简略'} onClick={() => setBrief(b => !b)} style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}>{brief ? '详细' : '简略'}</button>
58
- <button type="button" title="清空当前窗口" onClick={() => setCleared(true)} style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}>清空</button>
59
- </div>
60
- <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
61
- {shown.length === 0 ? (
62
- <div style={{ padding: 10, fontSize: 12, color: 'var(--nf-text-2)' }}>{connected ? '暂无实时调用。开始一次生成/审稿/计划即可看到。' : '正在连接 AI 实况服务…'}</div>
63
- ) : shown.map(s => <SessionCard key={s.sessionId} s={s} brief={brief} />)}
64
- </div>
65
- </div>
66
- )
67
- }
1
+ /**
2
+ * 「实时调用 / LIVE」—— 内嵌到 AI 进度窗的下半区(详情层),不再独立浮窗。
3
+ * 连接 /llm-live/stream,按 LLM 调用分组展示模型实时输出。
4
+ *
5
+ * 除实时输出外,这里还承担两件与「花了多少」有关的事:
6
+ * 1) 每次调用显示 token 用量(输入↑ / 输出↓ / 思考)与耗时、首字耗时;
7
+ * 2) 「查看 Prompt」按 sessionId 拉取这次调用**实际发送**的 system / user。
8
+ * 顶部另有一条「本次运行」累计条(进程内,重启归零)。
9
+ *
10
+ * 状态色全部走 panel.module.css 的 .lfPill / .lfDot(主题令牌 + --nf-streaming),
11
+ * 不再内联硬编码色;布局性 inline style 保留(均为令牌引用或动态值)。
12
+ */
13
+
14
+ import { useCallback, useEffect, useMemo, useState } from 'react'
15
+ import { useLlmLiveFeed, type LlmLiveSession } from '../llmLive.ts'
16
+ import { NOVEL_API, type LlmPromptRecord, type LlmUsageSummary } from '../../protocol.ts'
17
+ import css from './panel.module.css'
18
+
19
+ /** 毫秒 → 紧凑可读(840ms / 1.2s)。 */
20
+ function fmtMs(ms: number | undefined): string {
21
+ if (ms === undefined || ms <= 0) return ''
22
+ return ms < 1000 ? Math.round(ms) + 'ms' : (ms / 1000).toFixed(1) + 's'
23
+ }
24
+
25
+ /** token 数 → 紧凑(12.3k)。 */
26
+ function fmtTokens(n: number | undefined): string {
27
+ if (n === undefined || n <= 0) return '0'
28
+ return n < 1000 ? String(n) : (n / 1000).toFixed(1) + 'k'
29
+ }
30
+
31
+ /** 一次调用的用量摘要(输入↑ / 输出↓ / 思考 / 缓存)。 */
32
+ function usageText(s: LlmLiveSession): string {
33
+ const u = s.usage
34
+ if (u === undefined) return '用量未上报'
35
+ const parts = ['↑' + fmtTokens(u.inputTokens), '↓' + fmtTokens(u.outputTokens)]
36
+ if ((u.reasoningTokens ?? 0) > 0) parts.push('思考 ' + fmtTokens(u.reasoningTokens))
37
+ if ((u.cacheReadTokens ?? 0) > 0) parts.push('缓存 ' + fmtTokens(u.cacheReadTokens))
38
+ return parts.join(' · ')
39
+ }
40
+
41
+ function PhasePill({ s }: { s: LlmLiveSession }): JSX.Element {
42
+ return (
43
+ <span className={css.lfPill} data-phase={s.phase}>
44
+ {s.phase === 'requesting' ? '请求中' : s.phase === 'streaming' ? '生成中' : s.phase === 'failed' ? '失败' : '完成'}
45
+ </span>
46
+ )
47
+ }
48
+
49
+ /** 「查看 Prompt」弹层:拉取这次调用实际发送的 system + user。 */
50
+ function PromptDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }): JSX.Element {
51
+ const [record, setRecord] = useState<LlmPromptRecord | null>(null)
52
+ const [error, setError] = useState('')
53
+ const [tab, setTab] = useState<'user' | 'system'>('user')
54
+ useEffect(() => {
55
+ let cancelled = false
56
+ void (async () => {
57
+ try {
58
+ const res = await fetch(NOVEL_API.llmPrompt + '?sessionId=' + encodeURIComponent(sessionId))
59
+ if (!res.ok) {
60
+ const body = await res.json().catch(() => ({})) as { error?: string }
61
+ throw new Error(body.error ?? 'HTTP ' + res.status)
62
+ }
63
+ const data = await res.json() as LlmPromptRecord
64
+ if (!cancelled) setRecord(data)
65
+ } catch (err) {
66
+ if (!cancelled) setError((err as Error).message)
67
+ }
68
+ })()
69
+ return () => { cancelled = true }
70
+ }, [sessionId])
71
+ const text = record === null ? '' : (tab === 'user' ? (record.user ?? '') : (record.system ?? ''))
72
+ return (
73
+ <div
74
+ style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
75
+ onClick={onClose}
76
+ >
77
+ <div
78
+ onClick={e => e.stopPropagation()}
79
+ style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 10, width: 'min(880px, 96vw)', maxHeight: '86vh', display: 'flex', flexDirection: 'column', padding: 12, gap: 8 }}
80
+ >
81
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
82
+ <b style={{ flex: 1, fontSize: 13 }}>实际发送的 Prompt{record?.label !== undefined ? ' · ' + record.label : ''}</b>
83
+ {record !== null && <span className={css.meta}>共 {record.chars.toLocaleString()} 字符{record.truncated === true ? '(记录已截断)' : ''}</span>}
84
+ <button type="button" className={css.iconButton} title="关闭" onClick={onClose}>×</button>
85
+ </div>
86
+ <div style={{ display: 'flex', gap: 6 }}>
87
+ <button type="button" className={css.button + (tab === 'user' ? ' ' + css.buttonPrimary : '')} onClick={() => setTab('user')}>User / 输入</button>
88
+ <button type="button" className={css.button + (tab === 'system' ? ' ' + css.buttonPrimary : '')} onClick={() => setTab('system')}>System / 系统提示</button>
89
+ </div>
90
+ {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{error}</span>}
91
+ {record === null && error === '' && <span className={css.meta}>加载中…</span>}
92
+ {record !== null && (
93
+ <pre style={{ flex: 1, minHeight: 0, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, lineHeight: 1.55, background: 'var(--nf-bg-inset)', borderRadius: 8, padding: 8, margin: 0 }}>
94
+ {text === '' ? '(空)' : text}
95
+ </pre>
96
+ )}
97
+ </div>
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function SessionCard({ s, brief, onShowPrompt }: { s: LlmLiveSession; brief: boolean; onShowPrompt: (sessionId: string) => void }): JSX.Element {
103
+ const active = s.phase === 'requesting' || s.phase === 'streaming'
104
+ const dotState = active ? 'streaming' : s.phase
105
+ return (
106
+ <div style={{ borderBottom: '1px solid var(--nf-border)', padding: '7px 6px' }}>
107
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
108
+ <span className={css.lfDot} data-state={dotState} />
109
+ <b style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.label}</b>
110
+ {s.model !== undefined && <span style={{ color: 'var(--nf-text-2)', fontSize: 10, maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.model}</span>}
111
+ <PhasePill s={s} />
112
+ </div>
113
+ <div style={{ fontSize: 10, color: 'var(--nf-text-2)', marginTop: 2, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
114
+ <span>{s.totalChars} 字符</span>
115
+ <span title="输入 / 输出 token">{usageText(s)}</span>
116
+ {s.elapsedMs !== undefined && <span title="总耗时">用时 {fmtMs(s.elapsedMs)}</span>}
117
+ {s.firstTokenMs !== undefined && <span title="首个字返回耗时">首字 {fmtMs(s.firstTokenMs)}</span>}
118
+ {s.phaseMessage !== '' && <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.phaseMessage}</span>}
119
+ {s.hasPrompt === true && (
120
+ <button
121
+ type="button"
122
+ title="查看这次调用实际发送的 Prompt"
123
+ onClick={() => onShowPrompt(s.sessionId)}
124
+ style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '0 5px', cursor: 'pointer' }}
125
+ >查看 Prompt</button>
126
+ )}
127
+ </div>
128
+ {!brief && s.preview !== '' && (
129
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, lineHeight: 1.5, color: 'var(--nf-text)', backgroundColor: 'var(--nf-bg-inset)', borderRadius: 8, padding: 6, marginTop: 6, marginBottom: 0 }}>{s.preview}</pre>
130
+ )}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ export default function LiveFeedLog(): JSX.Element {
136
+ const [brief, setBrief] = useState(true)
137
+ const [cleared, setCleared] = useState(false)
138
+ const [promptId, setPromptId] = useState<string | null>(null)
139
+ const { connected, sessions } = useLlmLiveFeed(true)
140
+ const active = sessions.filter(s => s.phase === 'requesting' || s.phase === 'streaming').length
141
+ const shown = useMemo(() => (cleared ? sessions.slice(-8) : sessions), [sessions, cleared])
142
+
143
+ // 本次运行用量(进程内累计,来自 /status.usage)。
144
+ const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
145
+ const loadUsage = useCallback(async () => {
146
+ try {
147
+ const res = await fetch(NOVEL_API.status + '?slim=1')
148
+ if (!res.ok) return
149
+ const data = await res.json() as { usage?: LlmUsageSummary }
150
+ setUsage(data.usage ?? null)
151
+ } catch { /* 用量是尽力而为,不影响实况 */ }
152
+ }, [])
153
+ useEffect(() => {
154
+ void loadUsage()
155
+ const timer = window.setInterval(() => { void loadUsage() }, 15_000)
156
+ return () => { window.clearInterval(timer) }
157
+ }, [loadUsage])
158
+
159
+ const resetUsage = useCallback(async (): Promise<void> => {
160
+ try {
161
+ await fetch(NOVEL_API.usageReset, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
162
+ await loadUsage()
163
+ } catch { /* ignore */ }
164
+ }, [loadUsage])
165
+
166
+ return (
167
+ <div style={{ borderTop: '1px solid var(--nf-border)', marginTop: 8, paddingTop: 6, flex: '1 1 auto', minHeight: 140, display: 'flex', flexDirection: 'column' }}>
168
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '2px 4px' }}>
169
+ <span className={css.lfDot} data-size="lg" data-state={connected ? 'ok' : 'idle'} />
170
+ <b style={{ flex: 1 }}>实时调用 / LIVE</b>
171
+ <span className={css.lfPill} data-phase={active > 0 ? 'streaming' : 'requesting'}>
172
+ {active > 0 ? `${active} 项进行中` : connected ? '等待生成' : '正在连接'}
173
+ </span>
174
+ <button type="button" title={brief ? '详细' : '简略'} onClick={() => setBrief(b => !b)} style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}>{brief ? '详细' : '简略'}</button>
175
+ <button type="button" title="清空当前窗口" onClick={() => setCleared(true)} style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}>清空</button>
176
+ </div>
177
+ {usage !== null && usage.calls > 0 && (
178
+ <div
179
+ title="本次运行累计(进程内,重启归零)"
180
+ style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', fontSize: 10, color: 'var(--nf-text-2)', padding: '3px 4px', borderTop: '1px dashed var(--nf-border)' }}
181
+ >
182
+ <span>本次运行:{usage.calls} 次调用{usage.failed > 0 ? `(失败 ${usage.failed})` : ''}</span>
183
+ <span>↑{fmtTokens(usage.inputTokens)}</span>
184
+ <span>↓{fmtTokens(usage.outputTokens)}</span>
185
+ {usage.reasoningTokens > 0 && <span>思考 {fmtTokens(usage.reasoningTokens)}</span>}
186
+ <span>用时 {fmtMs(usage.elapsedMs)}</span>
187
+ {usage.callsWithoutUsage > 0 && <span>({usage.callsWithoutUsage} 次未上报用量)</span>}
188
+ <button
189
+ type="button"
190
+ title="清零本次运行用量"
191
+ onClick={() => { void resetUsage() }}
192
+ style={{ marginLeft: 'auto', border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}
193
+ >重新计数</button>
194
+ </div>
195
+ )}
196
+ <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
197
+ {shown.length === 0 ? (
198
+ <div style={{ padding: 10, fontSize: 12, color: 'var(--nf-text-2)' }}>{connected ? '暂无实时调用。开始一次生成/审稿/计划即可看到。' : '正在连接 AI 实况服务…'}</div>
199
+ ) : shown.map(s => <SessionCard key={s.sessionId} s={s} brief={brief} onShowPrompt={setPromptId} />)}
200
+ </div>
201
+ {promptId !== null && <PromptDialog sessionId={promptId} onClose={() => setPromptId(null)} />}
202
+ </div>
203
+ )
204
+ }
@@ -11,8 +11,8 @@ import { LLM_VENDORS } from '../../protocol.ts'
11
11
  import { tt } from './helpers.ts'
12
12
  import css from './panel.module.css'
13
13
 
14
- /** 目录不可用时回退的内置预设(历史行为)。 */
15
- export const FALLBACK_MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
14
+ /** 目录不可用时回退的内置预设(对齐当前模型目录)。 */
15
+ export const FALLBACK_MODEL_PRESETS = ['deepseek-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
16
16
 
17
17
  const CUSTOM = '__custom__'
18
18
  interface TestState { testing: boolean; ok?: boolean; ms?: number; message?: string }