@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
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
.
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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))
|
package/src/client/llmLive.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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 = {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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-
|
|
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 }
|