@waterwx/dsh-novel-forge 1.7.3 → 1.9.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/lib/client.js +3627 -447
- package/lib/client.js.map +1 -1
- package/lib/index.js +1127 -141
- package/lib/index.js.map +1 -1
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/client/api.d.ts +31 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +30 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/ModelPicker.d.ts +11 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +14 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/engine.d.ts +39 -1
- package/lib/types/index.d.ts +8 -0
- package/lib/types/protocol.d.ts +339 -0
- package/package.json +1 -1
- package/src/author-assets.ts +166 -0
- package/src/client/api.ts +82 -0
- package/src/client/locales.ts +32 -0
- package/src/client/mount.tsx._new +97 -0
- package/src/client/mount.tsx._nf +97 -0
- package/src/client/mount.tsx._tmp +97 -0
- package/src/client/panel/AdaptModeView.tsx +202 -0
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +107 -0
- package/src/client/panel/ImportModal.tsx +2 -1
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +73 -79
- package/src/client/panel/ProgressConsole.tsx +64 -0
- package/src/client/panel/ProgressHomeView.tsx +87 -0
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/SettingsView.tsx +292 -0
- package/src/client/panel/panel.module.css +325 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/engine.ts +442 -0
- package/src/index.ts +20 -0
- package/src/protocol.ts +356 -0
- package/src/routes.ts +323 -0
package/src/client/locales.ts
CHANGED
|
@@ -143,7 +143,23 @@ export const zh = {
|
|
|
143
143
|
'settings.provider': '模型提供商',
|
|
144
144
|
'settings.model': '模型',
|
|
145
145
|
'settings.modelCustom': '自定义…',
|
|
146
|
+
'settings.customProvider': '自定义提供商…',
|
|
147
|
+
'settings.edit': '编辑',
|
|
148
|
+
'settings.delete': '删除',
|
|
146
149
|
'settings.modelCustomPlaceholder': '输入任意模型 id',
|
|
150
|
+
'settings.savedModels': '我的模型',
|
|
151
|
+
'settings.modelCards': '模型卡片(内置 + 已添加)',
|
|
152
|
+
'settings.savedModelName': '名称',
|
|
153
|
+
'settings.addModel': '添加模型',
|
|
154
|
+
'settings.catalogRefresh': '刷新厂商',
|
|
155
|
+
'settings.refreshModels': '刷新模型',
|
|
156
|
+
'settings.savedModelsHint': '手动添加常用模型(provider + 模型 id),点击即可切换当前模型;厂商模式会写入 DSH 凭据并注册路由。',
|
|
157
|
+
'settings.testConnection': '测试连通',
|
|
158
|
+
'settings.testRunning': '测试中',
|
|
159
|
+
'settings.testOk': '连通',
|
|
160
|
+
'settings.testFail': '失败',
|
|
161
|
+
'settings.testHint': '真实发送一次最小调用(约几十 token),验证 Key / 端点 / 模型可用。',
|
|
162
|
+
'settings.reasoningSection': '推理强度',
|
|
147
163
|
'settings.reasoningEffort': '写作类任务思考强度 (reasoningEffort)',
|
|
148
164
|
'settings.reasoningHint': '章节生成/润色/重写/审稿等写作任务使用;关掉更省更快。',
|
|
149
165
|
'settings.analysisReasoning': '分析类任务思考强度(提炼/拆书/反推大纲)',
|
|
@@ -295,7 +311,23 @@ export const en: Record<string, string> = {
|
|
|
295
311
|
'settings.provider': 'Provider',
|
|
296
312
|
'settings.model': 'Model',
|
|
297
313
|
'settings.modelCustom': 'Custom…',
|
|
314
|
+
'settings.customProvider': 'Custom provider…',
|
|
315
|
+
'settings.edit': 'Edit',
|
|
316
|
+
'settings.delete': 'Delete',
|
|
298
317
|
'settings.modelCustomPlaceholder': 'Type any model id',
|
|
318
|
+
'settings.savedModels': 'My Models',
|
|
319
|
+
'settings.modelCards': 'Model cards (built-in + added)',
|
|
320
|
+
'settings.savedModelName': 'Name',
|
|
321
|
+
'settings.addModel': 'Add model',
|
|
322
|
+
'settings.catalogRefresh': 'Refresh vendors',
|
|
323
|
+
'settings.refreshModels': 'Refresh models',
|
|
324
|
+
'settings.savedModelsHint': 'Manually add a model (provider + model id); click to switch. Vendor mode writes the key into DSH credentials and registers the route.',
|
|
325
|
+
'settings.testConnection': 'Test',
|
|
326
|
+
'settings.testRunning': 'Testing',
|
|
327
|
+
'settings.testOk': 'OK',
|
|
328
|
+
'settings.testFail': 'Failed',
|
|
329
|
+
'settings.testHint': 'Sends one minimal real call (~dozens of tokens) to verify key / endpoint / model.',
|
|
330
|
+
'settings.reasoningSection': 'Reasoning',
|
|
299
331
|
'settings.reasoningEffort': 'Writing-task reasoning effort',
|
|
300
332
|
'settings.analysisReasoning': 'Analysis-task reasoning (extract / breakdown / reverse outline)',
|
|
301
333
|
'settings.analysisReasoningHint': 'One-off analysis tasks (roles/scenes/bible extract, breakdown, reverse outline) default to low; not affected by the writing-level switch.',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel view mounting — mirrors the family plugins: a container appended
|
|
3
|
+
* inside the conversation grid item, hidden while inactive; toggling is a
|
|
4
|
+
* data attribute on <html>, with cross-plugin activation events.
|
|
5
|
+
*/
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import type { NovelApi } from './api.ts'
|
|
8
|
+
import type { PanelController } from './panel/controller.ts'
|
|
9
|
+
import { NovelPanel } from './panel/NovelPanel.tsx'
|
|
10
|
+
import css from './panel/panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** The injected panel container. */
|
|
13
|
+
export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
|
|
14
|
+
|
|
15
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
16
|
+
const ACTIVE_ATTR = 'data-dsh-novelforge-active'
|
|
17
|
+
/** Sibling panels' activation attributes (evicted when this panel opens). */
|
|
18
|
+
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
19
|
+
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
20
|
+
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
21
|
+
const PANEL_NAME = 'novelforge'
|
|
22
|
+
|
|
23
|
+
/** Find the center column, or undefined while the frame is not mounted. */
|
|
24
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
25
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the panel React tree into the center column and bind visibility to
|
|
30
|
+
* the controller.
|
|
31
|
+
*/
|
|
32
|
+
export function mountPanel(controller: PanelController, api: NovelApi): () => void {
|
|
33
|
+
let root: Root | undefined
|
|
34
|
+
let container: HTMLDivElement | undefined
|
|
35
|
+
|
|
36
|
+
const ensure = (): void => {
|
|
37
|
+
if (container !== undefined) {
|
|
38
|
+
if (container.isConnected) return
|
|
39
|
+
root?.unmount()
|
|
40
|
+
root = undefined
|
|
41
|
+
container.remove()
|
|
42
|
+
container = undefined
|
|
43
|
+
}
|
|
44
|
+
const column = conversationColumn()
|
|
45
|
+
if (column === undefined) return
|
|
46
|
+
container = document.createElement('div')
|
|
47
|
+
container.dataset.dshNovelforgeView = 'true'
|
|
48
|
+
container.className = css.view
|
|
49
|
+
column.appendChild(container)
|
|
50
|
+
root = createRoot(container)
|
|
51
|
+
root.render(<NovelPanel controller={controller} api={api} />)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
55
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
56
|
+
|
|
57
|
+
const applyActive = (): void => {
|
|
58
|
+
if (controller.getSnapshot().panelOpen) {
|
|
59
|
+
for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
60
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
61
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
62
|
+
} else {
|
|
63
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const onOtherActivate = (event: Event): void => {
|
|
67
|
+
const detail = (event as CustomEvent).detail
|
|
68
|
+
if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
|
|
69
|
+
controller.close()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Jump out on sidebar context clicks.
|
|
73
|
+
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
74
|
+
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
75
|
+
if (!controller.getSnapshot().panelOpen) return
|
|
76
|
+
const target = event.target as HTMLElement | null
|
|
77
|
+
if (target === null) return
|
|
78
|
+
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
|
|
79
|
+
}
|
|
80
|
+
document.addEventListener('click', onClickSidebarRow, true)
|
|
81
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
82
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
83
|
+
applyActive()
|
|
84
|
+
ensure()
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener('click', onClickSidebarRow, true)
|
|
88
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
89
|
+
waitObserver.disconnect()
|
|
90
|
+
unsubscribe()
|
|
91
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
92
|
+
root?.unmount()
|
|
93
|
+
root = undefined
|
|
94
|
+
container?.remove()
|
|
95
|
+
container = undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel view mounting — mirrors the family plugins: a container appended
|
|
3
|
+
* inside the conversation grid item, hidden while inactive; toggling is a
|
|
4
|
+
* data attribute on <html>, with cross-plugin activation events.
|
|
5
|
+
*/
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import type { NovelApi } from './api.ts'
|
|
8
|
+
import type { PanelController } from './panel/controller.ts'
|
|
9
|
+
import { NovelPanel } from './panel/NovelPanel.tsx'
|
|
10
|
+
import css from './panel/panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** The injected panel container. */
|
|
13
|
+
export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
|
|
14
|
+
|
|
15
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
16
|
+
const ACTIVE_ATTR = 'data-dsh-novelforge-active'
|
|
17
|
+
/** Sibling panels' activation attributes (evicted when this panel opens). */
|
|
18
|
+
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
19
|
+
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
20
|
+
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
21
|
+
const PANEL_NAME = 'novelforge'
|
|
22
|
+
|
|
23
|
+
/** Find the center column, or undefined while the frame is not mounted. */
|
|
24
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
25
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the panel React tree into the center column and bind visibility to
|
|
30
|
+
* the controller.
|
|
31
|
+
*/
|
|
32
|
+
export function mountPanel(controller: PanelController, api: NovelApi): () => void {
|
|
33
|
+
let root: Root | undefined
|
|
34
|
+
let container: HTMLDivElement | undefined
|
|
35
|
+
|
|
36
|
+
const ensure = (): void => {
|
|
37
|
+
if (container !== undefined) {
|
|
38
|
+
if (container.isConnected) return
|
|
39
|
+
root?.unmount()
|
|
40
|
+
root = undefined
|
|
41
|
+
container.remove()
|
|
42
|
+
container = undefined
|
|
43
|
+
}
|
|
44
|
+
const column = conversationColumn()
|
|
45
|
+
if (column === undefined) return
|
|
46
|
+
container = document.createElement('div')
|
|
47
|
+
container.dataset.dshNovelforgeView = 'true'
|
|
48
|
+
container.className = css.view
|
|
49
|
+
column.appendChild(container)
|
|
50
|
+
root = createRoot(container)
|
|
51
|
+
root.render(<NovelPanel controller={controller} api={api} />)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
55
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
56
|
+
|
|
57
|
+
const applyActive = (): void => {
|
|
58
|
+
if (controller.getSnapshot().panelOpen) {
|
|
59
|
+
for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
60
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
61
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
62
|
+
} else {
|
|
63
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const onOtherActivate = (event: Event): void => {
|
|
67
|
+
const detail = (event as CustomEvent).detail
|
|
68
|
+
if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
|
|
69
|
+
controller.close()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Jump out on sidebar context clicks.
|
|
73
|
+
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
74
|
+
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
75
|
+
if (!controller.getSnapshot().panelOpen) return
|
|
76
|
+
const target = event.target as HTMLElement | null
|
|
77
|
+
if (target === null) return
|
|
78
|
+
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
|
|
79
|
+
}
|
|
80
|
+
document.addEventListener('click', onClickSidebarRow, true)
|
|
81
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
82
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
83
|
+
applyActive()
|
|
84
|
+
ensure()
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener('click', onClickSidebarRow, true)
|
|
88
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
89
|
+
waitObserver.disconnect()
|
|
90
|
+
unsubscribe()
|
|
91
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
92
|
+
root?.unmount()
|
|
93
|
+
root = undefined
|
|
94
|
+
container?.remove()
|
|
95
|
+
container = undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel view mounting — mirrors the family plugins: a container appended
|
|
3
|
+
* inside the conversation grid item, hidden while inactive; toggling is a
|
|
4
|
+
* data attribute on <html>, with cross-plugin activation events.
|
|
5
|
+
*/
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import type { NovelApi } from './api.ts'
|
|
8
|
+
import type { PanelController } from './panel/controller.ts'
|
|
9
|
+
import { NovelPanel } from './panel/NovelPanel.tsx'
|
|
10
|
+
import css from './panel/panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** The injected panel container. */
|
|
13
|
+
export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
|
|
14
|
+
|
|
15
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
16
|
+
const ACTIVE_ATTR = 'data-dsh-novelforge-active'
|
|
17
|
+
/** Sibling panels' activation attributes (evicted when this panel opens). */
|
|
18
|
+
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
19
|
+
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
20
|
+
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
21
|
+
const PANEL_NAME = 'novelforge'
|
|
22
|
+
|
|
23
|
+
/** Find the center column, or undefined while the frame is not mounted. */
|
|
24
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
25
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the panel React tree into the center column and bind visibility to
|
|
30
|
+
* the controller.
|
|
31
|
+
*/
|
|
32
|
+
export function mountPanel(controller: PanelController, api: NovelApi): () => void {
|
|
33
|
+
let root: Root | undefined
|
|
34
|
+
let container: HTMLDivElement | undefined
|
|
35
|
+
|
|
36
|
+
const ensure = (): void => {
|
|
37
|
+
if (container !== undefined) {
|
|
38
|
+
if (container.isConnected) return
|
|
39
|
+
root?.unmount()
|
|
40
|
+
root = undefined
|
|
41
|
+
container.remove()
|
|
42
|
+
container = undefined
|
|
43
|
+
}
|
|
44
|
+
const column = conversationColumn()
|
|
45
|
+
if (column === undefined) return
|
|
46
|
+
container = document.createElement('div')
|
|
47
|
+
container.dataset.dshNovelforgeView = 'true'
|
|
48
|
+
container.className = css.view
|
|
49
|
+
column.appendChild(container)
|
|
50
|
+
root = createRoot(container)
|
|
51
|
+
root.render(<NovelPanel controller={controller} api={api} />)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
55
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
56
|
+
|
|
57
|
+
const applyActive = (): void => {
|
|
58
|
+
if (controller.getSnapshot().panelOpen) {
|
|
59
|
+
for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
60
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
61
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
62
|
+
} else {
|
|
63
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const onOtherActivate = (event: Event): void => {
|
|
67
|
+
const detail = (event as CustomEvent).detail
|
|
68
|
+
if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
|
|
69
|
+
controller.close()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Jump out on sidebar context clicks.
|
|
73
|
+
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
74
|
+
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
75
|
+
if (!controller.getSnapshot().panelOpen) return
|
|
76
|
+
const target = event.target as HTMLElement | null
|
|
77
|
+
if (target === null) return
|
|
78
|
+
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
|
|
79
|
+
}
|
|
80
|
+
document.addEventListener('click', onClickSidebarRow, true)
|
|
81
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
82
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
83
|
+
applyActive()
|
|
84
|
+
ensure()
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener('click', onClickSidebarRow, true)
|
|
88
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
89
|
+
waitObserver.disconnect()
|
|
90
|
+
unsubscribe()
|
|
91
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
92
|
+
root?.unmount()
|
|
93
|
+
root = undefined
|
|
94
|
+
container?.remove()
|
|
95
|
+
container = undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 改编模式:P0 分析 → P1 改编方案(勾选/填新值→映射表/规则/影响) → P2 术语替换执行。
|
|
3
|
+
*/
|
|
4
|
+
import { useRef, useState } from 'react'
|
|
5
|
+
import { FileText, Wand2, Upload, Download, ListChecks, Play } from 'lucide-react'
|
|
6
|
+
import type { NovelApi } from '../api.ts'
|
|
7
|
+
import type { AdaptAnalyzeResponse, AdaptationProposal, AdaptExecuteResponse } from '../../protocol.ts'
|
|
8
|
+
import { readFileTextSmart } from '../text.ts'
|
|
9
|
+
import css from './panel.module.css'
|
|
10
|
+
|
|
11
|
+
const MUTABILITY_LABEL: Record<string, string> = {
|
|
12
|
+
locked: '🔒 建议保留', big: '🟡 可改影响大', small: '🟢 可改影响小', free: '🟣 可自由改', visual: '📦 仅视觉包装',
|
|
13
|
+
};
|
|
14
|
+
const MUTABILITY_CLS: Record<string, string> = {
|
|
15
|
+
locked: 'var(--nf-muted)', big: 'var(--nf-warn)', small: 'var(--nf-info)', free: 'var(--nf-accent)', visual: 'var(--nf-muted)',
|
|
16
|
+
};
|
|
17
|
+
const RISK_LABEL: Record<string, string> = { high: '高风险', medium: '中风险', low: '低风险' };
|
|
18
|
+
|
|
19
|
+
interface EditState { enabled: boolean; target: string }
|
|
20
|
+
|
|
21
|
+
export function AdaptModeView({ api }: { api: NovelApi }) {
|
|
22
|
+
const [text, setText] = useState('');
|
|
23
|
+
const [result, setResult] = useState<AdaptAnalyzeResponse | null>(null);
|
|
24
|
+
const [edits, setEdits] = useState<Record<string, EditState>>({});
|
|
25
|
+
const [proposal, setProposal] = useState<AdaptationProposal | null>(null);
|
|
26
|
+
const [executed, setExecuted] = useState<AdaptExecuteResponse | null>(null);
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
const [busyLabel, setBusyLabel] = useState('');
|
|
29
|
+
const [error, setError] = useState('');
|
|
30
|
+
const fileRef = useRef<HTMLInputElement | null>(null);
|
|
31
|
+
|
|
32
|
+
const onFile = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
|
33
|
+
const file = e.target.files?.[0];
|
|
34
|
+
if (file === undefined) return;
|
|
35
|
+
try { setText(await readFileTextSmart(file)) } catch (err) { setError((err as Error).message) }
|
|
36
|
+
e.target.value = '';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const analyze = async (): Promise<void> => {
|
|
40
|
+
if (text.trim().length < 200) { setError('请粘贴或上传完整小说全文(至少 200 字符)'); return }
|
|
41
|
+
setBusy(true); setBusyLabel('分析中…'); setError(''); setProposal(null); setExecuted(null);
|
|
42
|
+
try {
|
|
43
|
+
const res = await api.adaptAnalyze(text);
|
|
44
|
+
setResult(res);
|
|
45
|
+
const next: Record<string, EditState> = {};
|
|
46
|
+
for (const d of res.dimensions) {
|
|
47
|
+
next[d.key] = { enabled: d.mutability !== 'locked', target: d.candidates?.[0] ?? '' };
|
|
48
|
+
}
|
|
49
|
+
setEdits(next);
|
|
50
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const setEdit = (key: string, patch: Partial<EditState>): void => {
|
|
54
|
+
setEdits(prev => ({ ...prev, [key]: { ...prev[key], ...patch } }));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const selectedDims = result === null ? [] : result.dimensions.filter(d => {
|
|
58
|
+
const e = edits[d.key];
|
|
59
|
+
return e?.enabled === true && (e.target ?? '').trim() !== '';
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const buildSelections = () => selectedDims.map(d => ({ key: d.key, title: d.title, current: d.current, target: (edits[d.key]?.target ?? '').trim(), mutability: d.mutability }));
|
|
63
|
+
|
|
64
|
+
const propose = async (): Promise<void> => {
|
|
65
|
+
if (result === null || text.trim().length < 200) { setError('请先分析'); return }
|
|
66
|
+
const selections = buildSelections();
|
|
67
|
+
if (selections.length === 0) { setError('请至少选择一条要改的维度并填写新值'); return }
|
|
68
|
+
setBusy(true); setBusyLabel('生成改编方案…'); setError(''); setExecuted(null);
|
|
69
|
+
try {
|
|
70
|
+
const res = await api.adaptPropose({ text, selections, dimensions: result.dimensions });
|
|
71
|
+
setProposal(res.proposal);
|
|
72
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const execute = async (): Promise<void> => {
|
|
76
|
+
if (proposal === null || proposal.mappings.length === 0) { setError('请先生成改编方案'); return }
|
|
77
|
+
setBusy(true); setBusyLabel('执行术语替换…'); setError('');
|
|
78
|
+
try {
|
|
79
|
+
const res = await api.adaptExecute({ text, mappings: proposal.mappings, mode: 'replace' });
|
|
80
|
+
setExecuted(res);
|
|
81
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const downloadAdapted = (): void => {
|
|
85
|
+
if (executed === null) return;
|
|
86
|
+
const blob = new Blob([executed.adaptedText], { type: 'text/markdown;charset=utf-8' });
|
|
87
|
+
const url = URL.createObjectURL(blob);
|
|
88
|
+
const a = document.createElement('a');
|
|
89
|
+
a.href = url;
|
|
90
|
+
const name = result?.bookName !== undefined && result.bookName !== '' ? result.bookName + '·改编版.md' : '改编版.md';
|
|
91
|
+
a.download = name;
|
|
92
|
+
a.click();
|
|
93
|
+
URL.revokeObjectURL(url);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className={css.authorPageBody}>
|
|
98
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-6)' }}>
|
|
99
|
+
<h2 className={css.panelTitle} style={{ margin: 0 }}>🎬 改编模式</h2>
|
|
100
|
+
<span className={css.meta}>上传全文 → 分析可改范围 → 勾选/填新值 → 生成映射表 → 术语替换执行。</span>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className={css.adaptInputCard}>
|
|
104
|
+
<div className={css.rowEnd} style={{ justifyContent: 'space-between' }}>
|
|
105
|
+
<span className={css.meta}>粘贴全文,或上传 txt/md 文件</span>
|
|
106
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => fileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传文件</button>
|
|
107
|
+
</div>
|
|
108
|
+
<input ref={fileRef} type="file" accept=".txt,.md,text/plain,text/markdown" style={{ display: 'none' }} onChange={e => { void onFile(e) }} />
|
|
109
|
+
<textarea className={css.input + ' ' + css.adaptTextarea} value={text} onChange={e => setText(e.target.value)} placeholder="粘贴小说全文(含章节标题/分卷更好)…" />
|
|
110
|
+
<div className={css.rowEnd}><span className={css.meta}>已输入 {text.length} 字</span><button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void analyze() }} disabled={busy}><Wand2 size={14} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '分析中…' ? '分析中…' : '开始分析'}</button></div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{error !== '' && <div className={css.noticeError}>{error}</div>}
|
|
114
|
+
|
|
115
|
+
{result === null ? (
|
|
116
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
117
|
+
<span className={css.shelfEmptyIcon}><FileText size={30} /></span>
|
|
118
|
+
<span className={css.shelfEmptyTitle}>等待分析</span>
|
|
119
|
+
<span className={css.meta}>分析会读取全文并提炼「原文设定卡片」与「可改范围矩阵」。</span>
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
<div className={css.adaptResult}>
|
|
123
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
124
|
+
<h3 style={{ margin: 0 }}>《{result.bookName}》 · {result.chapters} 章</h3>
|
|
125
|
+
<span className={css.meta}>{result.note}</span>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{result.outline !== undefined && result.outline !== '' && (
|
|
129
|
+
<details className={css.adaptOutline}><summary>反推大纲(点击展开)</summary><pre className={css.adaptOutlinePre}>{result.outline}</pre></details>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
133
|
+
<b>可改范围矩阵(勾选要改 + 填新值)</b>
|
|
134
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void propose() }} disabled={busy}><ListChecks size={13} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '生成改编方案…' ? '生成中…' : '生成改编方案'} ({selectedDims.length})</button>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className={css.adaptMatrix}>
|
|
138
|
+
{result.dimensions.map((d, i) => {
|
|
139
|
+
const e = edits[d.key];
|
|
140
|
+
const enabled = e?.enabled === true;
|
|
141
|
+
const target = e?.target ?? '';
|
|
142
|
+
return (
|
|
143
|
+
<div key={d.key + '-' + i} className={css.dimCard + (enabled ? ' ' + css.dimCardSelected : '')}>
|
|
144
|
+
<div className={css.dimCardHead}>
|
|
145
|
+
<span className={css.dimTitle}>{d.title}</span>
|
|
146
|
+
<span className={css.badge} style={{ color: MUTABILITY_CLS[d.mutability] ?? 'var(--nf-muted)' }}>{MUTABILITY_LABEL[d.mutability] ?? d.mutability}</span>
|
|
147
|
+
<span className={css.meta}>风险:{RISK_LABEL[d.risk] ?? d.risk}</span>
|
|
148
|
+
</div>
|
|
149
|
+
<div className={css.dimCurrent}><b>当前值</b>:{d.current}</div>
|
|
150
|
+
{d.mutability !== 'locked' && (
|
|
151
|
+
<div className={css.dimEdit}>
|
|
152
|
+
<input type="checkbox" checked={enabled} onChange={e2 => setEdit(d.key, { enabled: e2.target.checked })} />
|
|
153
|
+
<input className={css.input} value={target} disabled={!enabled} placeholder="改为什么?" onChange={e2 => setEdit(d.key, { target: e2.target.value })} />
|
|
154
|
+
{(d.candidates ?? []).length > 0 && <span className={css.meta}>候选:{(d.candidates ?? []).join(' / ')}</span>}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
{(d.evidence !== undefined && d.evidence !== '') && <div className={css.meta}>证据:{d.evidence}</div>}
|
|
158
|
+
<div className={css.meta}>影响:{d.impact}</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{proposal !== null && (
|
|
165
|
+
<div className={css.adaptProposal}>
|
|
166
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
167
|
+
<b>改编方案</b>
|
|
168
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void execute() }} disabled={busy}><Play size={13} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '执行术语替换…' ? '执行中…' : '执行改编(术语替换)'}</button>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<h4 style={{ margin: '10px 0 4px' }}>映射表</h4>
|
|
172
|
+
<table className={css.adaptTable}><thead><tr><th>原值</th><th>新值</th><th>范围</th></tr></thead><tbody>
|
|
173
|
+
{proposal.mappings.map((m, i2) => (<tr key={i2}><td>{m.source}</td><td>{m.target}</td><td>{m.scope}</td></tr>))}
|
|
174
|
+
</tbody></table>
|
|
175
|
+
|
|
176
|
+
<h4 style={{ margin: '10px 0 4px' }}>改编规则</h4>
|
|
177
|
+
<div className={css.meta}><b>保留:</b>{proposal.rules.preserve.join(';') || '—'}</div>
|
|
178
|
+
<div className={css.meta}><b>允许变:</b>{proposal.rules.change.join(';') || '—'}</div>
|
|
179
|
+
<div className={css.meta}><b>红线:</b>{proposal.rules.constraints.join(';') || '—'}</div>
|
|
180
|
+
|
|
181
|
+
<h4 style={{ margin: '10px 0 4px' }}>联动影响</h4>
|
|
182
|
+
<div className={css.adaptImpacts}>
|
|
183
|
+
{proposal.impacts.map((im, i3) => (<div key={i3} className={css.adaptImpact}><b style={{ color: im.risk === 'high' ? 'var(--nf-error)' : im.risk === 'medium' ? 'var(--nf-warn)' : 'var(--nf-info)' }}>[{RISK_LABEL[im.risk] ?? im.risk}]</b> {im.item}:{im.detail}{im.chapters !== undefined && im.chapters.length > 0 ? `(章:${im.chapters.join(',')})` : ''}</div>))}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{executed !== null && (
|
|
189
|
+
<div className={css.adaptProposal}>
|
|
190
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
191
|
+
<b>改编结果(术语替换)</b>
|
|
192
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={downloadAdapted}><Download size={13} style={{ verticalAlign: -2 }} /> 下载改编版</button>
|
|
193
|
+
</div>
|
|
194
|
+
<div className={css.meta}>命中:{executed.hits.filter(h => h.count > 0).map(h => h.source + '→' + h.target + '×' + h.count).join(',') || '无命中'}</div>
|
|
195
|
+
<details className={css.adaptOutline}><summary>改编正文预览(点击展开)</summary><pre className={css.adaptOutlinePre}>{executed.adaptedText.slice(0, 8000)}</pre></details>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|