@waterwx/dsh-novel-forge 1.3.2-alpha → 1.3.3-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/lib/client.js +555 -466
- package/lib/index.js +531 -93
- package/lib/types/ai-scan.d.ts +0 -6
- package/lib/types/assets.d.ts +0 -2
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +8 -3
- package/lib/types/client/api.d.ts +1 -0
- package/lib/types/client/docx.d.ts +0 -2
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/views.d.ts +1 -33
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +15 -3
- package/lib/types/llm-live.d.ts +0 -5
- package/lib/types/market-radar-scan.d.ts +0 -1
- package/lib/types/model-capability.d.ts +93 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/protocol.d.ts +21 -5
- package/lib/types/routes.d.ts +5 -3
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/stage-contract.d.ts +1 -3
- package/package.json +13 -11
- package/src/ai-scan.ts +0 -24
- package/src/assets.ts +0 -5
- package/src/author-assets.ts +4 -22
- package/src/bookshelf.ts +13 -7
- package/src/client/api.ts +2 -2
- package/src/client/docx.ts +0 -5
- package/src/client/locales.ts +1 -0
- package/src/client/mount.tsx +3 -2
- package/src/client/panel/AuthorAssetsView.tsx +1 -1
- package/src/client/panel/ImportModal.tsx +1 -1
- package/src/client/panel/MarketRadarView.tsx +55 -17
- package/src/client/panel/SettingsView.tsx +2 -1
- package/src/client/panel/market-radar-state.ts +79 -0
- package/src/client/panel/panel.module.css +4 -4
- package/src/client/panel/views.tsx +1 -86
- package/src/client/sidebar-entry.ts +3 -3
- package/src/engine.ts +103 -16
- package/src/global-assets.ts +3 -4
- package/src/home.ts +33 -0
- package/src/index.ts +136 -51
- package/src/llm-live.ts +0 -15
- package/src/market-radar-scan.ts +2 -8
- package/src/model-capability.ts +215 -0
- package/src/output-budget.ts +147 -0
- package/src/protocol.ts +22 -5
- package/src/routes.ts +34 -11
- package/src/settings-persist.ts +114 -0
- package/src/stage-contract.ts +1 -4
- package/src/review-policy.ts +0 -161
|
@@ -12,9 +12,7 @@
|
|
|
12
12
|
* 阶段强约束只在作者没有指定具体对象(通用续写)时生效。
|
|
13
13
|
*/
|
|
14
14
|
import type { ChapterPlan, ProjectState, StageInfo } from './protocol.ts';
|
|
15
|
-
/**
|
|
16
|
-
export type BookStageId = StageInfo['id'];
|
|
17
|
-
/** 一个阶段契约。 */
|
|
15
|
+
/** 一个阶段契约(assistant 侧需要作为类型使用)。 */
|
|
18
16
|
export type BookStage = StageInfo;
|
|
19
17
|
/**
|
|
20
18
|
* 作者这一轮是否只说了「继续/下一步」这类没有具体对象的指令。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waterwx/dsh-novel-forge",
|
|
3
3
|
"description": "AI 编译纯文本小说工作台 for the dsh web GUI: import a novel outline (docx/text), plan volumes & chapters with LLM, generate chapter by chapter (3000-4000 chars each), auto AI review & book audit, and export the full book as Markdown/TXT into your chosen output folder.",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.3-alpha",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "waterwx",
|
|
7
7
|
"repository": {
|
|
@@ -62,16 +62,16 @@
|
|
|
62
62
|
"schemastery": "^3.18.0"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"@deepseek-ai/dsh-client-connection": "0.1.2-alpha.3",
|
|
66
|
-
"@deepseek-ai/dsh-client-locale": "0.1.2-alpha.3",
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-settings": "0.1.2-alpha.3",
|
|
68
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-alpha.3",
|
|
69
|
-
"@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.3",
|
|
70
|
-
"@deepseek-ai/dsh-llm": "0.1.2-alpha.3",
|
|
71
|
-
"@deepseek-ai/dsh-settings": "0.1.2-alpha.3",
|
|
72
|
-
"@deepseek-ai/dsh-skill": "0.1.2-alpha.3",
|
|
73
|
-
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.3",
|
|
74
|
-
"@deepseek-ai/dsh-tools": "0.1.2-alpha.3",
|
|
65
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.3",
|
|
66
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.3",
|
|
67
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.3",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.3",
|
|
69
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.3",
|
|
70
|
+
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.3",
|
|
71
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.3",
|
|
72
|
+
"@deepseek-ai/dsh-skill": "^0.1.2-alpha.3",
|
|
73
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.3",
|
|
74
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-alpha.3",
|
|
75
75
|
"react": "^18.2.0",
|
|
76
76
|
"react-dom": "^18.2.0"
|
|
77
77
|
},
|
|
@@ -114,6 +114,8 @@
|
|
|
114
114
|
"typecheck": "tsc --noEmit",
|
|
115
115
|
"check:styles": "node scripts/check-theme-sizes.mjs",
|
|
116
116
|
"check:third-party": "node scripts/check-third-party.mjs",
|
|
117
|
+
"check:contrast": "node scripts/audit-contrast.mjs",
|
|
118
|
+
"check:third-party:list": "node scripts/check-third-party.mjs --list",
|
|
117
119
|
"release": "node scripts/release.mjs",
|
|
118
120
|
"release:dry": "node scripts/release.mjs --dry-run",
|
|
119
121
|
"test": "vitest run"
|
package/src/ai-scan.ts
CHANGED
|
@@ -153,27 +153,3 @@ function countOccurrences(text: string, word: string): number {
|
|
|
153
153
|
}
|
|
154
154
|
return count
|
|
155
155
|
}
|
|
156
|
-
|
|
157
|
-
/** 扫描前后对比(润色时用) */
|
|
158
|
-
export function compareAiScan(before: AiScanResult, after: AiScanResult): {
|
|
159
|
-
improved: boolean
|
|
160
|
-
deltaScore: number
|
|
161
|
-
details: string[]
|
|
162
|
-
} {
|
|
163
|
-
const deltaScore = before.aiScore - after.aiScore
|
|
164
|
-
const details: string[] = []
|
|
165
|
-
if (deltaScore > 0) details.push(`AI 味指数下降 ${deltaScore} 分(${before.aiScore} → ${after.aiScore})`)
|
|
166
|
-
else if (deltaScore < 0) details.push(`AI 味指数上升 ${-deltaScore} 分(${before.aiScore} → ${after.aiScore}),需检查`)
|
|
167
|
-
else details.push(`AI 味指数持平(${before.aiScore})`)
|
|
168
|
-
|
|
169
|
-
const beforeCliches = before.clicheHits.reduce((a, b) => a + b.count, 0)
|
|
170
|
-
const afterCliches = after.clicheHits.reduce((a, b) => a + b.count, 0)
|
|
171
|
-
if (afterCliches < beforeCliches) details.push(`套话减少 ${beforeCliches - afterCliches} 处`)
|
|
172
|
-
else if (afterCliches > beforeCliches) details.push(`套话增加 ${afterCliches - beforeCliches} 处`)
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
improved: deltaScore > 0,
|
|
176
|
-
deltaScore,
|
|
177
|
-
details,
|
|
178
|
-
}
|
|
179
|
-
}
|
package/src/assets.ts
CHANGED
|
@@ -1554,11 +1554,6 @@ export function effectiveAntiAiRules(assets: ProjectAssets | undefined): AntiAiR
|
|
|
1554
1554
|
return [...BUILTIN_ANTI_AI_RULES.filter(r => !customKeys.has(keyOf(r))), ...custom]
|
|
1555
1555
|
}
|
|
1556
1556
|
|
|
1557
|
-
/** 安定 key:内置规则全局基线(globalBaselineEnabled)在新书/旧书一律生效。 */
|
|
1558
|
-
export function ensureGlobalBaseline(assets: ProjectAssets | undefined): ProjectAssets {
|
|
1559
|
-
return ensureBuiltinAssets(assets, 'missing_only')
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
1557
|
/** 内置库种子化 upsert(对齐上游 SystemResourceBootstrapService 精神)。
|
|
1563
1558
|
* missing_only 仅补齐缺失的全局基线规则;sync_existing 还会按 key 刷新已内置规则的结构化字段。 */
|
|
1564
1559
|
export function ensureBuiltinAssets(assets: ProjectAssets | undefined, mode: 'missing_only' | 'sync_existing' = 'missing_only'): ProjectAssets {
|
package/src/author-assets.ts
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 作者资产库/总数据(跨书)— 按作者维度聚合的可复用资产。
|
|
3
|
-
* 与书架同惯例持久化到
|
|
3
|
+
* 与书架同惯例持久化到 <DSH_HOME>/dsh-novel-forge-author-assets.json。
|
|
4
4
|
* 提供读取/持久化/新增或更新/删除,以及「导入默认」:把书架书的写作资产/角色 +
|
|
5
5
|
* 内置全局库(题材/反AI规则/风格模板/推进模式)批量沉淀成资产条目(默认库)。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
-
import { homedir } from 'node:os'
|
|
10
|
-
import { join } from 'node:path'
|
|
11
9
|
import { randomBytes } from 'node:crypto'
|
|
12
10
|
import type { AuthorAssetLibrary, AuthorStyleAsset } from './protocol.ts'
|
|
13
11
|
|
|
14
12
|
import { loadBookshelf } from './bookshelf.ts'
|
|
15
13
|
import { loadProject } from './engine.ts'
|
|
14
|
+
import { dshHome, dshHomePath } from './home.ts'
|
|
16
15
|
|
|
17
16
|
/** 作者资产库配置文件路径。 */
|
|
18
17
|
export function authorAssetsFile(): string {
|
|
19
|
-
return
|
|
18
|
+
return dshHomePath('dsh-novel-forge-author-assets.json')
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
/** 默认空资产库。 */
|
|
@@ -44,27 +43,10 @@ export function loadAuthorAssets(): AuthorAssetLibrary {
|
|
|
44
43
|
/** 持久化作者资产库。 */
|
|
45
44
|
export function saveAuthorAssets(library: AuthorAssetLibrary): void {
|
|
46
45
|
const file = authorAssetsFile()
|
|
47
|
-
mkdirSync(
|
|
46
|
+
mkdirSync(dshHome(), { recursive: true })
|
|
48
47
|
writeFileSync(file, JSON.stringify(library, null, 2), 'utf8')
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
/** 新建一条资产(自动生成 id 与时间戳)。 */
|
|
52
|
-
export function createAuthorAsset(input: Omit<AuthorStyleAsset, 'id' | 'createdAt' | 'updatedAt'>): AuthorStyleAsset {
|
|
53
|
-
const now = new Date().toISOString()
|
|
54
|
-
const asset: AuthorStyleAsset = {
|
|
55
|
-
...input,
|
|
56
|
-
id: 'aa-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex'),
|
|
57
|
-
sourceBooks: input.sourceBooks ?? [],
|
|
58
|
-
tags: input.tags ?? [],
|
|
59
|
-
createdAt: now,
|
|
60
|
-
updatedAt: now,
|
|
61
|
-
}
|
|
62
|
-
const library = loadAuthorAssets()
|
|
63
|
-
library.items.push(asset)
|
|
64
|
-
saveAuthorAssets(library)
|
|
65
|
-
return asset
|
|
66
|
-
}
|
|
67
|
-
|
|
68
50
|
/** 按 id 更新一条资产;不存在则追加。 */
|
|
69
51
|
export function upsertAuthorAsset(input: AuthorStyleAsset): AuthorAssetLibrary {
|
|
70
52
|
const library = loadAuthorAssets()
|
package/src/bookshelf.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
|
|
3
|
-
* 状态持久化到
|
|
3
|
+
* 状态持久化到 <DSH_HOME>/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
-
import { homedir } from 'node:os'
|
|
8
7
|
import { join } from 'node:path'
|
|
9
8
|
import { randomBytes } from 'node:crypto'
|
|
10
9
|
import type { BookEntry, BookshelfSnapshot } from './protocol.ts'
|
|
11
10
|
import { loadProject } from './engine.ts'
|
|
11
|
+
import { dshHome, dshHomePath } from './home.ts'
|
|
12
12
|
|
|
13
13
|
/** 书架配置文件路径。 */
|
|
14
14
|
export function bookshelfFile(): string {
|
|
15
|
-
return
|
|
15
|
+
return dshHomePath('dsh-novel-forge-bookshelf.json')
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
interface BookshelfStore {
|
|
@@ -44,7 +44,7 @@ export function loadBookshelf(): BookshelfStore {
|
|
|
44
44
|
/** 持久化书架。 */
|
|
45
45
|
function saveBookshelf(store: BookshelfStore): void {
|
|
46
46
|
const file = bookshelfFile()
|
|
47
|
-
mkdirSync(
|
|
47
|
+
mkdirSync(dshHome(), { recursive: true })
|
|
48
48
|
writeFileSync(file, JSON.stringify(store, null, 2), 'utf8')
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -180,8 +180,14 @@ export function repointBookDirs(from: string, to: string): number {
|
|
|
180
180
|
return count
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
/**
|
|
184
|
-
|
|
183
|
+
/**
|
|
184
|
+
* 新书默认输出目录:`<配置的输出目录>/书名`;未配置时回退 `<DSH_HOME>/novels/书名`。
|
|
185
|
+
* @param bookName - 书名(剔除文件系统非法字符并截断到 40 字)。
|
|
186
|
+
* @param baseDir - settings 里配置的输出目录(未经激活书合并的原始值)。
|
|
187
|
+
* @returns 该书应写入的绝对目录。
|
|
188
|
+
*/
|
|
189
|
+
export function defaultOutputDirFor(bookName: string, baseDir?: string): string {
|
|
185
190
|
const clean = bookName.replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 40) || '未命名小说'
|
|
186
|
-
|
|
191
|
+
const base = baseDir !== undefined && baseDir.trim() !== '' ? baseDir.trim() : dshHomePath('novels')
|
|
192
|
+
return join(base, clean)
|
|
187
193
|
}
|
package/src/client/api.ts
CHANGED
|
@@ -180,8 +180,8 @@ export class NovelApi {
|
|
|
180
180
|
return postJson<import('../protocol.ts').ChapterSaveResponse>(NOVEL_API.chapterSave, { chapterNo: no, text, report })
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
async patchConfig(patch: ConfigPatch): Promise<{ config: NovelConfig }> {
|
|
184
|
-
return postJson<{ config: NovelConfig }>(NOVEL_API.config, patch)
|
|
183
|
+
async patchConfig(patch: ConfigPatch): Promise<{ config: NovelConfig; settingsWarning?: string }> {
|
|
184
|
+
return postJson<{ config: NovelConfig; settingsWarning?: string }>(NOVEL_API.config, patch)
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/** 输出目录迁移:dryRun=true 仅预览目录内容;确认时传 to 搬迁。 */
|
package/src/client/docx.ts
CHANGED
|
@@ -30,8 +30,3 @@ export function extractDocxTextFromBuffer(buffer: ArrayBuffer | Uint8Array): str
|
|
|
30
30
|
}
|
|
31
31
|
return extractDocxTextFromXml(strFromU8(document))
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
/** Read a File as ArrayBuffer. */
|
|
35
|
-
export function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
|
36
|
-
return file.arrayBuffer()
|
|
37
|
-
}
|
package/src/client/locales.ts
CHANGED
|
@@ -136,6 +136,7 @@ export const zh = {
|
|
|
136
136
|
'settings.autoReviewAfterReviseHint': '按意见修订/润色产出草稿后自动附带一次 AI 审查,直接显示新稿评分与剩余问题(每章约 2-3k token,可关)',
|
|
137
137
|
'settings.save': '保存设置',
|
|
138
138
|
'settings.saved': '设置已保存',
|
|
139
|
+
'settings.savedUnpersisted': '设置已生效,但未能持久化(宿主未登记本插件的配置段),重启后会回到原值。',
|
|
139
140
|
'settings.openFolder': '打开输出文件夹',
|
|
140
141
|
'settings.exportTxt': '导出 TXT',
|
|
141
142
|
'settings.exportMd': '导出 Markdown',
|
package/src/client/mount.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import { NovelPanel } from './panel/NovelPanel.tsx'
|
|
|
10
10
|
import css from './panel/panel.module.css'
|
|
11
11
|
|
|
12
12
|
/** The injected panel container. */
|
|
13
|
-
|
|
13
|
+
const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
|
|
14
14
|
|
|
15
15
|
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
16
16
|
const ACTIVE_ATTR = 'data-dsh-novelforge-active'
|
|
@@ -44,7 +44,8 @@ export function mountPanel(controller: PanelController, api: NovelApi): () => vo
|
|
|
44
44
|
const column = conversationColumn()
|
|
45
45
|
if (column === undefined) return
|
|
46
46
|
container = document.createElement('div')
|
|
47
|
-
|
|
47
|
+
// 归属标记只在这里落地;取值与 PANEL_VIEW_SELECTOR 同源,字符串不写第二遍。
|
|
48
|
+
container.setAttribute(PANEL_VIEW_SELECTOR.slice(1, -1), 'true')
|
|
48
49
|
container.className = css.view
|
|
49
50
|
column.appendChild(container)
|
|
50
51
|
root = createRoot(container)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 作者资产库/总数据页:跨书可复用的笔法/红线/套路/角色模板/世界观模板。
|
|
3
|
-
* 支持 新增 / 编辑 / 删除;数据持久化到 ~/.dsh
|
|
3
|
+
* 支持 新增 / 编辑 / 删除;数据持久化到 Harness 数据目录(`$DSH_HOME`,未设时 ~/.dsh)。
|
|
4
4
|
*/
|
|
5
5
|
import { useEffect, useMemo, useState } from 'react'
|
|
6
6
|
import { Plus, Trash2, Pencil } from 'lucide-react'
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 「热门题材雷达」:真实榜单扫榜 → 候选勾选 → AI 分析 → 信号卡片 → 影响模式 → 用信号创作。
|
|
3
|
+
*
|
|
4
|
+
* 界面状态规则见 `market-radar-state.ts`(其中写明了 R1-R4 四条验收条件):
|
|
5
|
+
* - 扫榜**不销毁**已完成的产物:上一份分析与创意简报在新分析产出前继续可用,
|
|
6
|
+
* 作者不必为了改选范围而重扫一遍;
|
|
7
|
+
* - 信号勾选只在**换了报告**时重置,重渲染不得冲掉手选;
|
|
8
|
+
* - 三个入口的可用性统一由一个纯函数判定,避免多处各写一份条件而漂移。
|
|
3
9
|
*/
|
|
4
|
-
import { useMemo, useState } from 'react'
|
|
10
|
+
import { useMemo, useRef, useState } from 'react'
|
|
5
11
|
import type { NovelApi } from '../api.ts'
|
|
6
12
|
import type { MarketRadarResult, MarketCreativeBrief, IdeaInspirationResult } from '../../protocol.ts'
|
|
7
13
|
import css from './panel.module.css'
|
|
8
14
|
import { SubPage } from './SubPage.tsx'
|
|
15
|
+
import { isDifferentReport, radarControls } from './market-radar-state.ts'
|
|
9
16
|
|
|
10
17
|
const PLATFORMS = ['fanqie', 'qidian', 'jinjiang']
|
|
11
18
|
const PLATFORM_LABELS: Record<string, string> = { fanqie: '番茄小说', qidian: '起点中文网', jinjiang: '晋江文学城' }
|
|
@@ -43,6 +50,22 @@ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId
|
|
|
43
50
|
const [applied, setApplied] = useState('')
|
|
44
51
|
const [syncBusy, setSyncBusy] = useState(false)
|
|
45
52
|
const [syncedMsg, setSyncedMsg] = useState('')
|
|
53
|
+
/**
|
|
54
|
+
* 已应用报告 id。用 ref 而不是 state:它只用于「要不要复位勾选」的判断,
|
|
55
|
+
* 放进 state 会让每次分析都多一次重渲染,且可能触发不必要的复位。
|
|
56
|
+
*/
|
|
57
|
+
const appliedReportId = useRef('')
|
|
58
|
+
|
|
59
|
+
/** 三个入口的可用性统一由纯函数判定,避免多处条件漂移。 */
|
|
60
|
+
const controls = useMemo(
|
|
61
|
+
() => radarControls({
|
|
62
|
+
scanning,
|
|
63
|
+
analyzing,
|
|
64
|
+
pickedCount: selectedCandidates.size,
|
|
65
|
+
hasReport: result !== null,
|
|
66
|
+
}),
|
|
67
|
+
[scanning, analyzing, selectedCandidates, result],
|
|
68
|
+
)
|
|
46
69
|
|
|
47
70
|
const candKey = (g: ScanGroup, item: ScanItem): string => `${g.platform}:${g.listKey}:${item.rank}`
|
|
48
71
|
const togglePlatform = (p: string): void => {
|
|
@@ -56,15 +79,14 @@ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId
|
|
|
56
79
|
const scan = async (): Promise<void> => {
|
|
57
80
|
setScanning(true)
|
|
58
81
|
setError('')
|
|
59
|
-
setResult(null)
|
|
60
|
-
setBrief(null)
|
|
61
82
|
try {
|
|
62
83
|
const r = await api.marketRadarScan({ platforms })
|
|
63
84
|
const groups = r.result.groups
|
|
64
85
|
setScanGroups(groups)
|
|
65
86
|
const firstP = PLATFORMS.find(p => groups.some(g => g.platform === p))
|
|
66
87
|
if (firstP !== undefined) setViewPlatform(firstP)
|
|
67
|
-
//
|
|
88
|
+
// 勾选在扫榜后清空(换了候选范围),但**已完成的 AI 分析与创意简报保留**:
|
|
89
|
+
// 作者改选范围时不必丢掉上一份结论重新分析一次。
|
|
68
90
|
setSelectedCandidates(new Set())
|
|
69
91
|
} catch (err) {
|
|
70
92
|
setError((err as Error).message)
|
|
@@ -104,12 +126,19 @@ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId
|
|
|
104
126
|
for (const g of scanGroups) if (g.status === 'ok') for (const it of g.items) if (selectedCandidates.has(candKey(g, it))) candidates.push({ title: it.title, author: it.author, tags: it.tags, synopsis: it.synopsis, category: it.category })
|
|
105
127
|
setAnalyzing(true)
|
|
106
128
|
setError('')
|
|
107
|
-
setBrief(null)
|
|
108
129
|
try {
|
|
109
130
|
const r = await api.marketRadar({ candidates, feedText: feedText.trim() })
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
131
|
+
const nextResult = r.result
|
|
132
|
+
// 只有换了报告才重置信号勾选:重渲染/重复分析不得冲掉作者手选。
|
|
133
|
+
if (isDifferentReport(appliedReportId.current, nextResult.reportId ?? '')) {
|
|
134
|
+
appliedReportId.current = nextResult.reportId ?? ''
|
|
135
|
+
const rec = nextResult.signals.filter(s => s.recommended === true).map(s => s.id)
|
|
136
|
+
setSelectedIds(new Set(rec.slice(0, MAX_SIGNALS)))
|
|
137
|
+
}
|
|
138
|
+
setResult(nextResult)
|
|
139
|
+
// 简报是绑定旧报告结论的产物,报告一换即失效(避免与信号不匹配)。
|
|
140
|
+
setBrief(null)
|
|
141
|
+
setIdeas(null)
|
|
113
142
|
} catch (err) {
|
|
114
143
|
setError((err as Error).message)
|
|
115
144
|
} finally {
|
|
@@ -211,16 +240,25 @@ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId
|
|
|
211
240
|
<textarea className={css.input} style={{ minHeight: 90, resize: 'vertical' }} value={feedText} onChange={e => setFeedText(e.target.value)} placeholder="粘贴一份公开榜单文本…" />
|
|
212
241
|
</div>
|
|
213
242
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
214
|
-
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={
|
|
243
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={!controls.canScan} onClick={() => { void scan() }}>
|
|
215
244
|
{scanning ? '正在扫榜…' : '扫榜'}
|
|
216
245
|
</button>
|
|
217
246
|
{scanning && <span style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>正在抓取公开榜单({platforms.map(p => PLATFORM_LABELS[p]).join('、')}),多平台并行,约 10–30 秒…</span>}
|
|
218
247
|
{scanGroups !== null && (
|
|
219
|
-
<button type="button" className={css.button} style={{ marginLeft: 8 }} disabled={
|
|
220
|
-
{analyzing
|
|
248
|
+
<button type="button" className={css.button} style={{ marginLeft: 8 }} disabled={!controls.canAnalyze} onClick={() => { void analyze() }}>
|
|
249
|
+
{analyzing
|
|
250
|
+
? '分析中…'
|
|
251
|
+
: controls.analyzeLabel === 'again'
|
|
252
|
+
? `生成新分析(${selectedCandidates.size} 本)`
|
|
253
|
+
: `开始 分析(${selectedCandidates.size} 本)`}
|
|
221
254
|
</button>
|
|
222
255
|
)}
|
|
223
256
|
</div>
|
|
257
|
+
{scanGroups !== null && controls.analyzeLabel === 'pick' && (
|
|
258
|
+
<span style={{ fontSize: 12, color: 'var(--nf-text-2)' }}>
|
|
259
|
+
{result !== null ? '上一份分析仍在下方可查看;改选作品后可生成新的分析(无需重新扫榜)。' : '请先勾选要分析的作品。'}
|
|
260
|
+
</span>
|
|
261
|
+
)}
|
|
224
262
|
</div>
|
|
225
263
|
|
|
226
264
|
{error !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 13 }}> {error}</div>}
|
|
@@ -241,8 +279,8 @@ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId
|
|
|
241
279
|
})}
|
|
242
280
|
</div>
|
|
243
281
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
244
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={selectAll}>全选当前平台</button>
|
|
245
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={clearSelection}>清空</button>
|
|
282
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={!controls.canSelectCandidates} onClick={selectAll}>全选当前平台</button>
|
|
283
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={!controls.canSelectCandidates} onClick={clearSelection}>清空</button>
|
|
246
284
|
<span style={{ fontSize: 11, color: 'var(--nf-text-2)' }}>已选 {selectedCandidates.size} 条</span>
|
|
247
285
|
</div>
|
|
248
286
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 'var(--nf-space-10)' }}>
|
|
@@ -251,7 +289,7 @@ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId
|
|
|
251
289
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 600 }}>
|
|
252
290
|
{g.listLabel}
|
|
253
291
|
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 99, color: g.status === 'ok' ? 'var(--nf-success)' : 'var(--nf-error)', background: g.status === 'ok' ? 'rgba(79,191,139,0.14)' : 'rgba(212,99,79,0.16)' }}>{g.status === 'ok' ? `${g.items.length} 条` : '读取失败'}</span>
|
|
254
|
-
{g.status === 'ok' && <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => selectGroup(g)}>全选本榜</button>}
|
|
292
|
+
{g.status === 'ok' && <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={!controls.canSelectCandidates} onClick={() => selectGroup(g)}>全选本榜</button>}
|
|
255
293
|
</div>
|
|
256
294
|
{g.status === 'error' && <div style={{ fontSize: 11, color: 'var(--nf-error)', marginTop: 4 }}>{g.error}</div>}
|
|
257
295
|
{g.status === 'ok' && (
|
|
@@ -260,8 +298,8 @@ export default function MarketRadarView({ api, bookId }: { api: NovelApi; bookId
|
|
|
260
298
|
const key = candKey(g, it)
|
|
261
299
|
const sel = selectedCandidates.has(key)
|
|
262
300
|
return (
|
|
263
|
-
<label key={key} onClick={e => { if ((e.target as HTMLElement).tagName !== 'INPUT') toggleCandidate(key) }} style={{ display: 'flex', alignItems: 'flex-start', gap: 7, padding: '5px 2px', fontSize: 13.5, cursor: 'pointer' }}>
|
|
264
|
-
<input type="checkbox" checked={sel} onChange={() => toggleCandidate(key)} onClick={e => e.stopPropagation()} style={{ marginTop: 3 }} />
|
|
301
|
+
<label key={key} onClick={e => { if (!controls.canSelectCandidates) return; if ((e.target as HTMLElement).tagName !== 'INPUT') toggleCandidate(key) }} style={{ display: 'flex', alignItems: 'flex-start', gap: 7, padding: '5px 2px', fontSize: 13.5, cursor: controls.canSelectCandidates ? 'pointer' : 'not-allowed', opacity: controls.canSelectCandidates ? 1 : 0.6 }}>
|
|
302
|
+
<input type="checkbox" checked={sel} disabled={!controls.canSelectCandidates} onChange={() => toggleCandidate(key)} onClick={e => e.stopPropagation()} style={{ marginTop: 3 }} />
|
|
265
303
|
<span style={{ lineHeight: 1.5 }}>
|
|
266
304
|
<b style={{ fontSize: 14 }}>{it.rank}. {it.title}</b>
|
|
267
305
|
{it.author !== undefined && it.author !== '' ? <span style={{ color: 'var(--nf-text-2)', fontSize: 12.5 }}>({it.author})</span> : null}
|
|
@@ -124,7 +124,8 @@ export function SettingsView({ api, variant = 'page', onSettingsTab, onEditorFon
|
|
|
124
124
|
});
|
|
125
125
|
setConfig(result.config);
|
|
126
126
|
setConfigDraft(result.config);
|
|
127
|
-
|
|
127
|
+
// 未持久化时如实告知:改动只在本进程内生效,不能让作者以为已经存住了。
|
|
128
|
+
setNotice(result.settingsWarning === undefined ? tt('settings.saved') : tt('settings.savedUnpersisted'));
|
|
128
129
|
} catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
129
130
|
};
|
|
130
131
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 热门题材雷达的界面状态规则。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界:本文件只回答「界面上此刻能不能点」,不持有任何数据。抽成纯函数的
|
|
5
|
+
* 理由是它可以离线测——组件渲染不好测,判定逻辑好测。
|
|
6
|
+
*
|
|
7
|
+
* 下面先用**独立表述的验收条件**写清楚要什么,再由两个函数实现;这样即使日后
|
|
8
|
+
* 需求变化,改动方向也是照着条件走,而不是照着某个外部实现的形状走。
|
|
9
|
+
*
|
|
10
|
+
* R1 扫榜或分析进行中,不得改动作品勾选(否则分析输入与界面不一致);
|
|
11
|
+
* R2 未勾选任何作品时不能开始分析;
|
|
12
|
+
* R3 已经有报告之后,仍允许改选作品并**再次**分析 —— 不得退化成"只能查看";
|
|
13
|
+
* R4 信号勾选只在确实换了一份报告时复位;同一份报告的重渲染不得冲掉作者手选。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 雷达三个入口此刻的可用性,以及分析按钮该显示哪种文案。 */
|
|
17
|
+
export interface RadarControls {
|
|
18
|
+
/** 扫榜按钮是否可点。 */
|
|
19
|
+
canScan: boolean
|
|
20
|
+
/** 作品勾选(含全选 / 清空)是否可改。 */
|
|
21
|
+
canSelectCandidates: boolean
|
|
22
|
+
/** 分析按钮是否可点。 */
|
|
23
|
+
canAnalyze: boolean
|
|
24
|
+
/**
|
|
25
|
+
* 分析按钮的文案标识:
|
|
26
|
+
* - `wait`:还在扫榜,先等榜单;
|
|
27
|
+
* - `pick`:还没勾选作品;
|
|
28
|
+
* - `first`:首次分析;
|
|
29
|
+
* - `again`:已有报告,这次是再出一份。
|
|
30
|
+
*/
|
|
31
|
+
analyzeLabel: 'wait' | 'pick' | 'first' | 'again'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 实现 R1-R3:判定三个入口的可用性。
|
|
36
|
+
* @param state - 当前界面状态。
|
|
37
|
+
* @returns 各入口可用性与分析按钮文案标识。
|
|
38
|
+
*/
|
|
39
|
+
export function radarControls(state: {
|
|
40
|
+
/** 正在抓榜单。 */
|
|
41
|
+
scanning: boolean
|
|
42
|
+
/** 正在做 AI 分析。 */
|
|
43
|
+
analyzing: boolean
|
|
44
|
+
/** 已勾选的作品数。 */
|
|
45
|
+
pickedCount: number
|
|
46
|
+
/** 界面上是否已有可查看的报告。 */
|
|
47
|
+
hasReport: boolean
|
|
48
|
+
}): RadarControls {
|
|
49
|
+
if (state.analyzing) {
|
|
50
|
+
// 分析中:三个入口都锁住(R1),按钮文案沿用上一次的状态不必区分。
|
|
51
|
+
return { canScan: false, canSelectCandidates: false, canAnalyze: false, analyzeLabel: 'first' }
|
|
52
|
+
}
|
|
53
|
+
if (state.scanning) {
|
|
54
|
+
return { canScan: false, canSelectCandidates: false, canAnalyze: false, analyzeLabel: 'wait' }
|
|
55
|
+
}
|
|
56
|
+
if (state.pickedCount === 0) {
|
|
57
|
+
return { canScan: true, canSelectCandidates: true, canAnalyze: false, analyzeLabel: 'pick' }
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
canScan: true,
|
|
61
|
+
canSelectCandidates: true,
|
|
62
|
+
canAnalyze: true,
|
|
63
|
+
// R3:有报告也只是换个文案,**不禁用**再分析。
|
|
64
|
+
analyzeLabel: state.hasReport ? 'again' : 'first',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 实现 R4:本次报告是否**不是**勾选当前所依据的那一份。
|
|
70
|
+
*
|
|
71
|
+
* 传入的是上一次已经据以复位过的报告标识:相同 → 不动作者勾选;不同 → 勾选所依据
|
|
72
|
+
* 的信号集合已经变了,需要按新报告重新给出推荐勾选。
|
|
73
|
+
* @param selectionBasis - 勾选当前依据的报告标识(空串表示尚未复位过)。
|
|
74
|
+
* @param incoming - 本次拿到的报告标识(空串表示旧版产物没有该标识)。
|
|
75
|
+
* @returns 需要按新报告重置勾选时为 true。
|
|
76
|
+
*/
|
|
77
|
+
export function isDifferentReport(selectionBasis: string, incoming: string): boolean {
|
|
78
|
+
return selectionBasis !== incoming
|
|
79
|
+
}
|
|
@@ -212,7 +212,7 @@ body[data-ds-dark-theme] .entry {
|
|
|
212
212
|
--nf-text: #26221b;
|
|
213
213
|
--nf-text-2: #6f665a;
|
|
214
214
|
--nf-text-2-alpha: rgba(111, 102, 90, 0.7);
|
|
215
|
-
--nf-text-3: #
|
|
215
|
+
--nf-text-3: #8f8679;
|
|
216
216
|
--nf-accent: #a63e2a;
|
|
217
217
|
--nf-accent-hover: #8c3321;
|
|
218
218
|
--nf-accent-soft: rgba(166, 62, 42, 0.12);
|
|
@@ -293,11 +293,11 @@ body[data-ds-dark-theme] .view:not(:has(> .panel[data-nf-mode='light'])) {
|
|
|
293
293
|
--nf-text-2: #b3a78f;
|
|
294
294
|
--nf-text-2-alpha: rgba(179, 167, 143, 0.7);
|
|
295
295
|
--nf-text-3: #8a7f6c;
|
|
296
|
-
--nf-accent: #
|
|
296
|
+
--nf-accent: #c0492d;
|
|
297
297
|
--nf-accent-hover: #d95a3a;
|
|
298
298
|
--nf-accent-soft: rgba(193, 74, 46, 0.14);
|
|
299
299
|
--nf-accent-fg: #fdf3ee;
|
|
300
|
-
--nf-seal: #
|
|
300
|
+
--nf-seal: #c0492d;
|
|
301
301
|
--nf-seal-soft: rgba(193, 74, 46, 0.14);
|
|
302
302
|
--nf-ai-soft: rgba(217, 138, 43, 0.12);
|
|
303
303
|
--nf-human-soft: rgba(74, 163, 217, 0.12);
|
|
@@ -2703,7 +2703,7 @@ select.input {
|
|
|
2703
2703
|
--nf-text: #26221b;
|
|
2704
2704
|
--nf-text-2: #6f665a;
|
|
2705
2705
|
--nf-text-2-alpha: rgba(111, 102, 90, 0.72);
|
|
2706
|
-
--nf-text-3: #
|
|
2706
|
+
--nf-text-3: #8f8679;
|
|
2707
2707
|
--nf-accent: #a63e2a;
|
|
2708
2708
|
--nf-accent-hover: #8c3321;
|
|
2709
2709
|
--nf-accent-soft: rgba(166, 62, 42, 0.14);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
6
|
import type { ReactElement } from 'react'
|
|
7
|
-
import type {
|
|
7
|
+
import type { Plotline, PlotlineHealthReport, PlotlinePlan, RoleRecord, RoleStatusCard } from '../../protocol.ts'
|
|
8
8
|
import { tt, ROLE_LABELS, roleColor, kindLabel, plotlineStatusLabel, plotlineStatusColor } from './helpers.ts'
|
|
9
9
|
import css from './panel.module.css'
|
|
10
10
|
|
|
@@ -62,75 +62,6 @@ export function SlideNav<T extends string>(props: {
|
|
|
62
62
|
)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** 统计格:状态摘要条 / 资产健康通用。 */
|
|
66
|
-
export function StatCell(props: {
|
|
67
|
-
label: string
|
|
68
|
-
value: string
|
|
69
|
-
detail: string
|
|
70
|
-
/** 值颜色(可选)。 */
|
|
71
|
-
valueColor?: string
|
|
72
|
-
/** 值字号覆盖(如长文本用 13)。 */
|
|
73
|
-
valueFontSize?: number
|
|
74
|
-
/** detail 悬浮提示(可选)。 */
|
|
75
|
-
detailTitle?: string
|
|
76
|
-
}): ReactElement {
|
|
77
|
-
return (
|
|
78
|
-
<div className={css.assetStat}>
|
|
79
|
-
<span className={css.assetStatLabel}>{props.label}</span>
|
|
80
|
-
<span className={css.assetStatValue} style={{ color: props.valueColor, fontSize: props.valueFontSize }}>
|
|
81
|
-
{props.value}
|
|
82
|
-
</span>
|
|
83
|
-
<span className={css.assetStatDetail} title={props.detailTitle}>{props.detail}</span>
|
|
84
|
-
</div>
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** 待办队列行。 */
|
|
89
|
-
export function TodoRow(props: {
|
|
90
|
-
tone: 'danger' | 'warning' | 'info' | 'success'
|
|
91
|
-
title: string
|
|
92
|
-
description: string
|
|
93
|
-
actionLabel: string
|
|
94
|
-
disabled: boolean
|
|
95
|
-
onAction: () => void
|
|
96
|
-
}): ReactElement {
|
|
97
|
-
return (
|
|
98
|
-
<div className={`${css.todoItem} ${props.tone === 'danger' ? css.todoDanger : props.tone === 'warning' ? css.todoWarning : css.todoInfo}`}>
|
|
99
|
-
<span className={css.todoText}>
|
|
100
|
-
{props.title}
|
|
101
|
-
{props.description !== '' && <span className={css.meta}> — {props.description}</span>}
|
|
102
|
-
</span>
|
|
103
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onAction}>
|
|
104
|
-
{props.actionLabel}
|
|
105
|
-
</button>
|
|
106
|
-
</div>
|
|
107
|
-
)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** 全书质检问题行。 */
|
|
111
|
-
export function AuditIssueRow(props: {
|
|
112
|
-
issue: AuditIssue
|
|
113
|
-
disabled: boolean
|
|
114
|
-
onFix: () => void
|
|
115
|
-
}): ReactElement {
|
|
116
|
-
const { issue } = props
|
|
117
|
-
return (
|
|
118
|
-
<div className={`${css.todoItem} ${issue.severity === 'high' ? css.todoDanger : issue.severity === 'medium' ? css.todoWarning : css.todoInfo}`}>
|
|
119
|
-
<span className={css.todoText}>
|
|
120
|
-
<span>
|
|
121
|
-
{issue.chapterNo > 0 ? `第 ${issue.chapterNo} 章` : '未定位章节'} · [{issue.severity}] {issue.item}
|
|
122
|
-
</span>
|
|
123
|
-
{issue.suggestion !== '' && <span className={css.meta}>建议:{issue.suggestion}</span>}
|
|
124
|
-
</span>
|
|
125
|
-
{issue.chapterNo > 0 && (
|
|
126
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={props.disabled} onClick={props.onFix}>
|
|
127
|
-
去修订
|
|
128
|
-
</button>
|
|
129
|
-
)}
|
|
130
|
-
</div>
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
65
|
/** 剧情线卡片(列表主体)。 */
|
|
135
66
|
export function PlotlineCard(props: {
|
|
136
67
|
line: Plotline
|
|
@@ -383,19 +314,3 @@ export function EmptyState(props: {
|
|
|
383
314
|
</div>
|
|
384
315
|
)
|
|
385
316
|
}
|
|
386
|
-
|
|
387
|
-
/** 骨架屏基元:rows 条微光占位行;widths 指定各行宽度档位,循环取用。 */
|
|
388
|
-
export function SkeletonLines(props: {
|
|
389
|
-
rows?: number
|
|
390
|
-
widths?: Array<'full' | 'wide' | 'half'>
|
|
391
|
-
}): ReactElement {
|
|
392
|
-
const rows = props.rows ?? 3
|
|
393
|
-
const widths = props.widths ?? ['full', 'wide', 'half']
|
|
394
|
-
return (
|
|
395
|
-
<div className={css.skeletonLines} aria-hidden="true">
|
|
396
|
-
{Array.from({ length: rows }, (_, i) => (
|
|
397
|
-
<span key={i} className={css.skeletonLine} data-w={widths[i % widths.length]} />
|
|
398
|
-
))}
|
|
399
|
-
</div>
|
|
400
|
-
)
|
|
401
|
-
}
|