@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.
Files changed (53) hide show
  1. package/lib/client.js +555 -466
  2. package/lib/index.js +531 -93
  3. package/lib/types/ai-scan.d.ts +0 -6
  4. package/lib/types/assets.d.ts +0 -2
  5. package/lib/types/author-assets.d.ts +1 -3
  6. package/lib/types/bookshelf.d.ts +8 -3
  7. package/lib/types/client/api.d.ts +1 -0
  8. package/lib/types/client/docx.d.ts +0 -2
  9. package/lib/types/client/locales.d.ts +1 -0
  10. package/lib/types/client/mount.d.ts +0 -2
  11. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  12. package/lib/types/client/panel/views.d.ts +1 -33
  13. package/lib/types/client/sidebar-entry.d.ts +0 -2
  14. package/lib/types/home.d.ts +11 -0
  15. package/lib/types/index.d.ts +15 -3
  16. package/lib/types/llm-live.d.ts +0 -5
  17. package/lib/types/market-radar-scan.d.ts +0 -1
  18. package/lib/types/model-capability.d.ts +93 -0
  19. package/lib/types/output-budget.d.ts +86 -0
  20. package/lib/types/protocol.d.ts +21 -5
  21. package/lib/types/routes.d.ts +5 -3
  22. package/lib/types/settings-persist.d.ts +59 -0
  23. package/lib/types/stage-contract.d.ts +1 -3
  24. package/package.json +13 -11
  25. package/src/ai-scan.ts +0 -24
  26. package/src/assets.ts +0 -5
  27. package/src/author-assets.ts +4 -22
  28. package/src/bookshelf.ts +13 -7
  29. package/src/client/api.ts +2 -2
  30. package/src/client/docx.ts +0 -5
  31. package/src/client/locales.ts +1 -0
  32. package/src/client/mount.tsx +3 -2
  33. package/src/client/panel/AuthorAssetsView.tsx +1 -1
  34. package/src/client/panel/ImportModal.tsx +1 -1
  35. package/src/client/panel/MarketRadarView.tsx +55 -17
  36. package/src/client/panel/SettingsView.tsx +2 -1
  37. package/src/client/panel/market-radar-state.ts +79 -0
  38. package/src/client/panel/panel.module.css +4 -4
  39. package/src/client/panel/views.tsx +1 -86
  40. package/src/client/sidebar-entry.ts +3 -3
  41. package/src/engine.ts +103 -16
  42. package/src/global-assets.ts +3 -4
  43. package/src/home.ts +33 -0
  44. package/src/index.ts +136 -51
  45. package/src/llm-live.ts +0 -15
  46. package/src/market-radar-scan.ts +2 -8
  47. package/src/model-capability.ts +215 -0
  48. package/src/output-budget.ts +147 -0
  49. package/src/protocol.ts +22 -5
  50. package/src/routes.ts +34 -11
  51. package/src/settings-persist.ts +114 -0
  52. package/src/stage-contract.ts +1 -4
  53. package/src/review-policy.ts +0 -161
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Config-patch persistence for the novel-forge plugin.
3
+ *
4
+ * Historically the plugin only knew how to write through the host settings
5
+ * service (`ctx.settings.update`) and treated the section's name as a constant.
6
+ * From Harness 0.2 the section is inferred from this plugin's exported `Config`
7
+ * and addressed by the **entry id** the Loader assigned — which the plugin does
8
+ * not control. A wrong id makes every save fail, so this module keeps the split
9
+ * explicit: apply the patch to the live config, then try to persist it, and
10
+ * report (rather than throw) when the host cannot.
11
+ */
12
+
13
+ /** Minimal structural view of the host settings service this plugin touches. */
14
+ export interface SettingsLike {
15
+ /** Persist one section patch; absent on hosts that expose read APIs only. */
16
+ update?: (ns: string, patch: Record<string, unknown>) => Promise<unknown>
17
+ /** Live section registry; absence means the host cannot enumerate sections. */
18
+ describe?: () => unknown
19
+ }
20
+
21
+ /** Where a config patch ended up. */
22
+ export interface ConfigPersistResult {
23
+ /** The merged config (live in memory regardless of persistence). */
24
+ config: Record<string, unknown>
25
+ /**
26
+ * Why persistence did not happen, when it did not. Callers surface this to the
27
+ * author: a save that silently evaporates on restart is worse than a loud one.
28
+ */
29
+ settingsWarning?: string
30
+ }
31
+
32
+ /**
33
+ * The Loader entry id the settings section is addressed by.
34
+ * @param ctx - plugin context (structurally typed: only `fiber.entry.options.id` is read).
35
+ * @returns the entry id, or undefined when the host exposes none.
36
+ */
37
+ export function novelSettingsNamespace(ctx: unknown): string | undefined {
38
+ const id = (ctx as { fiber?: { entry?: { options?: { id?: unknown } } } } | undefined)
39
+ ?.fiber?.entry?.options?.id
40
+ return typeof id === 'string' && id.trim() !== '' ? id : undefined
41
+ }
42
+
43
+ /**
44
+ * Pick the settings section to write through.
45
+ *
46
+ * The entry id is authoritative; the declared legacy name is used only when the
47
+ * host actually registered a section under it. Guessing an id is never allowed:
48
+ * a write to a wrong section would persist another plugin's config.
49
+ * @param settings - the settings service, when present.
50
+ * @param entryNs - entry id resolved from the context, when available.
51
+ * @param legacyNs - this plugin's declared section name (0.1.x wiring).
52
+ * @returns the section id to write to, or undefined when none is addressable.
53
+ */
54
+ export function resolveSettingsTarget(
55
+ settings: SettingsLike | undefined,
56
+ entryNs: string | undefined,
57
+ legacyNs: string,
58
+ ): string | undefined {
59
+ const describe = settings?.describe
60
+ if (settings?.update === undefined) return undefined
61
+ if (describe === undefined) return entryNs
62
+ try {
63
+ const rows = describe() as Array<{ ns?: unknown }> | undefined
64
+ const names = (rows ?? []).map(row => row?.ns).filter((ns): ns is string => typeof ns === 'string')
65
+ if (entryNs !== undefined && names.includes(entryNs)) return entryNs
66
+ return names.includes(legacyNs) ? legacyNs : undefined
67
+ } catch {
68
+ // A host that cannot be queried is assumed to accept the entry id it gave us.
69
+ return entryNs
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Merge one patch into the live config and persist it when the host allows.
75
+ *
76
+ * Never throws for a persistence failure: the edit is valid and already applied,
77
+ * so failing the request would lose it and misreport a working save as an error.
78
+ * @param current - the config as currently resolved.
79
+ * @param patch - the subset to merge (`undefined` values are ignored by callers).
80
+ * @param settings - the settings service, when present.
81
+ * @param entryNs - entry id resolved from the context, when available.
82
+ * @param legacyNs - this plugin's declared section name.
83
+ * @returns the merged config plus a warning when it could not be persisted.
84
+ */
85
+ export async function persistConfigPatch(
86
+ current: Record<string, unknown>,
87
+ patch: Record<string, unknown>,
88
+ settings: SettingsLike | undefined,
89
+ entryNs: string | undefined,
90
+ legacyNs: string,
91
+ ): Promise<ConfigPersistResult> {
92
+ const next = { ...current, ...patch }
93
+ const target = resolveSettingsTarget(settings, entryNs, legacyNs)
94
+ if (settings?.update === undefined) {
95
+ return { config: next, settingsWarning: '宿主未提供 settings.update,配置只在本次进程内生效。' }
96
+ }
97
+ if (target === undefined) {
98
+ // List what the host did register: without it the author only sees "not
99
+ // registered" and cannot tell a wrong entry id from a missing section.
100
+ let registered = '不可枚举'
101
+ try {
102
+ const rows = settings.describe?.() as Array<{ ns?: unknown }> | undefined
103
+ registered = (rows ?? []).map(row => row?.ns).filter((ns): ns is string => typeof ns === 'string').join('、') || '(空)'
104
+ } catch { /* keep the neutral placeholder */ }
105
+ return { config: next, settingsWarning: `宿主 settings 未登记本插件的配置段(entry id「${entryNs ?? '未知'}」,已登记的段:${registered}),配置只在本次进程内生效。` }
106
+ }
107
+ try {
108
+ await settings.update(target, patch)
109
+ return { config: next }
110
+ } catch (error) {
111
+ const detail = error instanceof Error ? error.message : String(error)
112
+ return { config: next, settingsWarning: `写入宿主设置段「${target}」失败:${detail};配置只在本次进程内生效。` }
113
+ }
114
+ }
@@ -14,10 +14,7 @@
14
14
 
15
15
  import type { ChapterPlan, ProjectState, StageInfo } from './protocol.ts'
16
16
 
17
- /** 创作阶段标识(线上形状见 protocol.ts 的 StageInfo)。 */
18
- export type BookStageId = StageInfo['id']
19
-
20
- /** 一个阶段契约。 */
17
+ /** 一个阶段契约(assistant 侧需要作为类型使用)。 */
21
18
  export type BookStage = StageInfo
22
19
 
23
20
  /** 大纲视为「已开书」的最短长度。 */
@@ -1,161 +0,0 @@
1
- /**
2
- * 审稿规则注册表
3
- *
4
- * 生成用 compact 模式(核心规则),审稿用 full 模式(全部规则),
5
- * 消除 reviewSystemPrompt 内嵌规则与 renderAllAssets() 两套打架的问题。
6
- */
7
-
8
- /** 通用写作资产规则(兼容 ProjectAssets 中的各类规则) */
9
- export interface WritingRule {
10
- name: string
11
- description: string
12
- type?: string
13
- enabled?: boolean
14
- }
15
-
16
- export interface ReviewRule {
17
- id: string
18
- /** 规则维度 */
19
- dimension: 'character' | 'setting' | 'redline' | 'writing' | 'pacing' | 'logic' | 'anti-ai' | 'presentation' | 'compliance'
20
- /** 严重度 */
21
- severity: 'high' | 'medium' | 'low'
22
- /** 规则类型:forbidden=禁止类(命中即问题),encourage=鼓励类(不命中不算错) */
23
- type: 'forbidden' | 'encourage'
24
- /** 规则描述 */
25
- description: string
26
- /** 是否启用 */
27
- enabled: boolean
28
- /** 作用域 */
29
- scope: Array<'writing' | 'review' | 'polish' | 'all'>
30
- }
31
-
32
- /** 内置审稿维度(结构化评分用) */
33
- export const REVIEW_DIMENSIONS = [
34
- { id: 'character', name: '人设一致性', weight: 0.2 },
35
- { id: 'setting', name: '设定一致性', weight: 0.15 },
36
- { id: 'redline', name: '红线检查', weight: 0.15 },
37
- { id: 'writing', name: '文笔质量', weight: 0.15 },
38
- { id: 'pacing', name: '节奏与爽点', weight: 0.1 },
39
- { id: 'logic', name: '逻辑漏洞', weight: 0.1 },
40
- { id: 'anti-ai', name: '反 AI 规则', weight: 0.05 },
41
- { id: 'presentation', name: '呈现方式', weight: 0.05 },
42
- { id: 'compliance', name: '内容合规', weight: 0.05 },
43
- ] as const
44
-
45
- export type ReviewDimensionId = typeof REVIEW_DIMENSIONS[number]['id']
46
-
47
- /** 维度结构化评分 */
48
- export interface DimensionScore {
49
- dimension: ReviewDimensionId
50
- score: number // 0-100
51
- note: string
52
- }
53
-
54
- /**
55
- * 渲染审稿规则(compact 或 full 模式)
56
- *
57
- * compact: 只渲染禁止类 + high 严重度(用于生成时的约束)
58
- * full: 渲染全部启用规则(用于审稿时的检查清单)
59
- */
60
- export function renderReviewRules(
61
- assets: WritingRule[] | undefined,
62
- mode: 'compact' | 'full' = 'full',
63
- ): string {
64
- const lines: string[] = []
65
-
66
- // 内置核心规则(所有模式都注入)
67
- const coreRules = [
68
- { dimension: 'character', desc: '角色行为符合角色卡设定(性格/目标/知情度/说话方式)' },
69
- { dimension: 'setting', desc: '金手指规则、战力体系、世界观与道藏一致' },
70
- { dimension: 'redline', desc: '不触犯本书写作红线' },
71
- { dimension: 'compliance', desc: '不触犯内容合规红线(最高优先级)' },
72
- { dimension: 'logic', desc: '无前后矛盾、时间线错误、对话失真' },
73
- { dimension: 'presentation', desc: '不纯内心推理铺陈;反派有行动力;重要配角有辨识度' },
74
- ]
75
-
76
- if (mode === 'compact') {
77
- lines.push('写作约束(核心规则):')
78
- for (const r of coreRules) {
79
- lines.push(`- ${r.desc}`)
80
- }
81
- } else {
82
- lines.push('审稿维度(逐条审查):')
83
- REVIEW_DIMENSIONS.forEach((d, i) => {
84
- const core = coreRules.find(c => c.dimension === d.id)
85
- lines.push(`${i + 1}. ${d.name}:${core?.desc ?? '按维度标准审查'}`)
86
- })
87
- lines.push('')
88
- lines.push('反 AI 规则:逐条核对下方资产规则,命中即列为问题。')
89
- lines.push('呈现方式:整章是否纯内心推理铺陈(无对话/无对抗);反派是否纯背景板;重要配角是否无名标签化——命中即列为问题。')
90
- lines.push('内容合规(最高优先级):逐条核对合规红线,任何一条命中(含影射、暗示、详细描写)必须列为 high。')
91
- }
92
-
93
- // 注入用户自定义资产规则
94
- if (assets !== undefined && assets.length > 0) {
95
- const forbidden = assets.filter(a => a.type === 'forbidden' || a.type === 'rule')
96
- const encourage = assets.filter(a => a.type === 'encourage' || a.type === 'style')
97
- if (forbidden.length > 0) {
98
- lines.push('')
99
- lines.push('禁止类规则(命中即问题):')
100
- for (const a of forbidden) {
101
- lines.push(`- ${a.name}:${a.description}`)
102
- }
103
- }
104
- if (encourage.length > 0 && mode === 'full') {
105
- lines.push('')
106
- lines.push('鼓励类规则(不命中不算错,作为低优先级建议):')
107
- for (const a of encourage) {
108
- lines.push(`- ${a.name}:${a.description}`)
109
- }
110
- }
111
- }
112
-
113
- return lines.join('\n')
114
- }
115
-
116
- /**
117
- * 从审稿 issues 中按维度统计
118
- * 用于结构化评分和 UI 展示。
119
- */
120
- export function groupIssuesByDimension(
121
- issues: Array<{ severity: string; item: string; dimension?: string }>,
122
- ): Record<string, Array<{ severity: string; item: string }>> {
123
- const result: Record<string, Array<{ severity: string; item: string }>> = {}
124
- for (const issue of issues) {
125
- const dim = issue.dimension ?? inferDimension(issue.item)
126
- if (result[dim] === undefined) result[dim] = []
127
- result[dim]!.push({ severity: issue.severity, item: issue.item })
128
- }
129
- return result
130
- }
131
-
132
- /** 根据问题描述推断维度(兼容旧报告,没有 dimension 字段时用) */
133
- function inferDimension(item: string): ReviewDimensionId {
134
- if (/人设|角色|性格|口吻|知情|OOC/.test(item)) return 'character'
135
- if (/设定|世界观|战力|金手指|规则|道藏/.test(item)) return 'setting'
136
- if (/红线|后宫|擦边|碾压|圣母/.test(item)) return 'redline'
137
- if (/语病|翻译腔|套话|文笔|流水账|AI/.test(item)) return 'writing'
138
- if (/节奏|拖沓|灌水|爽点|钩子|悬念/.test(item)) return 'pacing'
139
- if (/矛盾|时间线|逻辑|对话失真/.test(item)) return 'logic'
140
- if (/合规|敏感|色情|暴力|政治|未成年/.test(item)) return 'compliance'
141
- if (/内心推理|背景板|标签化|瘦高个|灰衣人/.test(item)) return 'presentation'
142
- return 'writing'
143
- }
144
-
145
- /**
146
- * 计算维度加权总分
147
- * 用于审稿报告的结构化评分。
148
- */
149
- export function calculateWeightedScore(
150
- dimensions: DimensionScore[],
151
- ): number {
152
- let total = 0
153
- let weightSum = 0
154
- for (const d of dimensions) {
155
- const def = REVIEW_DIMENSIONS.find(x => x.id === d.dimension)
156
- const weight = def?.weight ?? 0.1
157
- total += d.score * weight
158
- weightSum += weight
159
- }
160
- return weightSum > 0 ? Math.round(total / weightSum) : 60
161
- }