@waterwx/dsh-novel-forge 2.0.0-alpha.1 → 2.0.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.
- package/README.md +286 -285
- package/lib/client.js +19561 -19439
- package/lib/index.js +8159 -4456
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +0 -6
- package/lib/types/assets.d.ts +20 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +13 -3
- package/lib/types/client/api.d.ts +136 -27
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +15 -70
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorHome.d.ts +13 -11
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/SettingsView.d.ts +12 -9
- package/lib/types/client/panel/ShelfView.d.ts +10 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +21 -35
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +78 -103
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +26 -12
- package/lib/types/llm-live.d.ts +70 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +18 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/model-capability.d.ts +93 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +816 -601
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +9 -3
- package/lib/types/run.d.ts +23 -1
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +58 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +50 -25
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +0 -24
- package/src/assets.ts +1801 -879
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +131 -147
- package/src/bookshelf.ts +193 -167
- package/src/client/api.ts +767 -617
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +32 -69
- package/src/client/index.ts +2 -0
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +15 -274
- package/src/client/mount.tsx +3 -2
- package/src/client/panel/AdaptModeView.tsx +535 -520
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +205 -207
- package/src/client/panel/AuthorHome.tsx +309 -204
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -352
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +302 -302
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +415 -0
- package/src/client/panel/ModelManager.tsx +205 -205
- package/src/client/panel/NovelPanel.tsx +3144 -2205
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -64
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/RunPanel.tsx +56 -24
- package/src/client/panel/SettingsView.tsx +175 -106
- package/src/client/panel/ShelfView.tsx +63 -39
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/market-radar-state.ts +79 -0
- package/src/client/panel/panel.module.css +5767 -4360
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +316 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +3 -3
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +1197 -1638
- package/src/global-assets.ts +58 -0
- package/src/home.ts +33 -0
- package/src/index.ts +181 -101
- package/src/llm-live.ts +245 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +35 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/model-capability.ts +215 -0
- package/src/novel-context.ts +286 -287
- package/src/output-budget.ts +147 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +835 -614
- package/src/revision.ts +283 -0
- package/src/routes.ts +1286 -910
- package/src/run.ts +113 -17
- package/src/settings-persist.ts +114 -0
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +186 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/guoman-2d-gufeng.webp +0 -0
- package/assets/styles/guoman-3d-tianyuan.webp +0 -0
- package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/hyperreal-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/sandiao-qban.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/urban-lightreal-3d.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/client/panel/FlowGuide.d.ts +0 -22
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
- package/lib/types/client/panel/ModelPicker.d.ts +0 -11
- package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/client/panel/PropLibrary.d.ts +0 -8
- package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
- package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/lib/types/manga-genre-rules.d.ts +0 -22
- package/lib/types/prompt-utils.d.ts +0 -35
- package/lib/types/review-policy.d.ts +0 -97
- package/lib/types/shot-language.d.ts +0 -46
- package/lib/types/story-beat-language.d.ts +0 -35
- package/lib/types/style-library.d.ts +0 -33
- package/skills/chapter-batch/SKILL.md +0 -81
- package/src/client/mount.tsx._new +0 -97
- package/src/client/mount.tsx._nf +0 -97
- package/src/client/mount.tsx._tmp +0 -97
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/client/panel/FlowGuide.tsx +0 -84
- package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
- package/src/client/panel/MangaWorkspace.tsx +0 -715
- package/src/client/panel/ProgressHomeView.tsx +0 -87
- package/src/client/panel/PropLibrary.tsx +0 -175
- package/src/client/panel/SceneLibrary.tsx +0 -453
- package/src/client/panel/StoryboardTab.tsx +0 -472
- package/src/client/panel/StyleCard.tsx +0 -90
- package/src/client/panel/StyleGallery.tsx +0 -58
- package/src/comic-presets.ts +0 -67
- package/src/manga-genre-rules.ts +0 -87
- package/src/prompt-utils.ts +0 -73
- package/src/review-policy.ts +0 -161
- package/src/shot-language.ts +0 -181
- package/src/story-beat-language.ts +0 -130
- package/src/style-library.ts +0 -84
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 书内知识库:添加/删除参考资料,生成/规划时自动检索注入。
|
|
3
|
+
* T2 主从布局:左列文档列表 + 新建入口,右侧文档详情 / 新建表单。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
|
+
import type { NovelApi } from '../api.ts'
|
|
7
|
+
import type { KnowledgeDoc } from '../../protocol.ts'
|
|
8
|
+
import css from './panel.module.css'
|
|
9
|
+
|
|
10
|
+
export default function KnowledgeBaseView({ api }: { api: NovelApi }): JSX.Element {
|
|
11
|
+
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
|
|
12
|
+
const [title, setTitle] = useState('')
|
|
13
|
+
const [content, setContent] = useState('')
|
|
14
|
+
const [busy, setBusy] = useState(false)
|
|
15
|
+
const [notice, setNotice] = useState('')
|
|
16
|
+
const [error, setError] = useState('')
|
|
17
|
+
/** 左列选中的文档 id;null 且 addMode=false 时显示空态。 */
|
|
18
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
19
|
+
/** 新建模式:右栏显示添加表单。 */
|
|
20
|
+
const [addMode, setAddMode] = useState(false)
|
|
21
|
+
|
|
22
|
+
const load = async (): Promise<void> => {
|
|
23
|
+
try {
|
|
24
|
+
const r = await api.knowledgeList()
|
|
25
|
+
setDocs(r.docs)
|
|
26
|
+
} catch (e) { setError((e as Error).message) }
|
|
27
|
+
}
|
|
28
|
+
useEffect(() => { void load() }, [])
|
|
29
|
+
|
|
30
|
+
const add = async (): Promise<void> => {
|
|
31
|
+
if (title.trim() === '') { setError('标题不能为空'); return }
|
|
32
|
+
setBusy(true); setError(''); setNotice('')
|
|
33
|
+
try {
|
|
34
|
+
const r = await api.knowledgeAdd({ title: title.trim(), content: content.trim() })
|
|
35
|
+
setDocs(r.docs); setTitle(''); setContent(''); setNotice(' 已添加')
|
|
36
|
+
setAddMode(false)
|
|
37
|
+
const added = r.docs[r.docs.length - 1]
|
|
38
|
+
if (added !== undefined) setSelectedId(added.id)
|
|
39
|
+
} catch (e) { setError((e as Error).message) } finally { setBusy(false) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const remove = async (id: string): Promise<void> => {
|
|
43
|
+
setBusy(true); setError('')
|
|
44
|
+
try {
|
|
45
|
+
const r = await api.knowledgeRemove(id)
|
|
46
|
+
setDocs(r.docs)
|
|
47
|
+
if (selectedId === id) setSelectedId(null)
|
|
48
|
+
} catch (e) { setError((e as Error).message) } finally { setBusy(false) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const selected = docs.find(d => d.id === selectedId)
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={css.authorPageBodyFlow}>
|
|
55
|
+
<div className={css.card} style={{ padding: 0, overflow: 'hidden' }}>
|
|
56
|
+
<div className={css.t2Master} style={{ minHeight: 420 }}>
|
|
57
|
+
<aside className={css.t2Rail} aria-label="知识库文档">
|
|
58
|
+
<div className={css.t2RailHead}> 书内知识库</div>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className={css.t2RailBtn}
|
|
62
|
+
data-active={addMode ? '' : undefined}
|
|
63
|
+
onClick={() => { setAddMode(true); setSelectedId(null) }}
|
|
64
|
+
title="添加一篇参考文档(生成时按章节检索注入)"
|
|
65
|
+
>
|
|
66
|
+
<span aria-hidden>+</span><span>新建文档</span>
|
|
67
|
+
</button>
|
|
68
|
+
<div className={css.t2RailSep} />
|
|
69
|
+
{docs.length === 0 ? (
|
|
70
|
+
<div className={css.t2RailMeta}>暂无文档</div>
|
|
71
|
+
) : docs.map(d => (
|
|
72
|
+
<button
|
|
73
|
+
key={d.id}
|
|
74
|
+
type="button"
|
|
75
|
+
role="tab"
|
|
76
|
+
aria-selected={!addMode && selectedId === d.id}
|
|
77
|
+
className={css.t2RailBtn}
|
|
78
|
+
data-active={!addMode && selectedId === d.id ? '' : undefined}
|
|
79
|
+
title={d.content.slice(0, 80)}
|
|
80
|
+
onClick={() => { setAddMode(false); setSelectedId(d.id) }}
|
|
81
|
+
>
|
|
82
|
+
<span aria-hidden></span>
|
|
83
|
+
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</span>
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
<div className={css.t2RailSep} />
|
|
87
|
+
<div className={css.t2RailMeta}>共 {docs.length} 篇 · 正文生成时按当前章节检索注入</div>
|
|
88
|
+
</aside>
|
|
89
|
+
<div className={css.t2Content}>
|
|
90
|
+
{addMode ? (
|
|
91
|
+
<>
|
|
92
|
+
<span className={css.cardTitleLg}>+ 新建文档</span>
|
|
93
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', maxWidth: 'var(--nf-measure, 720px)' }}>
|
|
94
|
+
<input className={css.input} value={title} onChange={e => setTitle(e.target.value)} placeholder="标题(如:天机锁设定 / 止水驿关系表)" />
|
|
95
|
+
<textarea className={css.input} style={{ minHeight: 160, resize: 'vertical' }} value={content} onChange={e => setContent(e.target.value)} placeholder="内容(将被检索注入生成提示)" />
|
|
96
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
|
|
97
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void add() }}>添加</button>
|
|
98
|
+
<button type="button" className={css.button} onClick={() => { setAddMode(false) }}>取消</button>
|
|
99
|
+
{notice !== '' && <span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-success)' }}>{notice}</span>}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</>
|
|
103
|
+
) : selected !== undefined ? (
|
|
104
|
+
<>
|
|
105
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
106
|
+
<span className={css.cardTitleLg}> {selected.title}</span>
|
|
107
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void remove(selected.id) }}>删除</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div className={`${css.planBeats} ${css.docsSurface}`}>
|
|
110
|
+
<div style={{ fontSize: 'var(--nf-fs-14)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>{selected.content !== '' ? selected.content : '(空文档)'}</div>
|
|
111
|
+
</div>
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<div className={css.planDetailEmpty} style={{ minHeight: 300 }}>
|
|
115
|
+
<span className={css.meta}>← 从左侧选择文档,或「+ 新建文档」添加参考资料。</span>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
{error !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-14)' }}> {error}</div>}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<span className={css.meta}>自由参考文档:零散设定/资料/碎片放这里;结构化设定(世界观/角色/境界/红线)请放「本书设定」。</span>
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
}
|