@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
|
@@ -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
|
+
}
|
package/src/stage-contract.ts
CHANGED
|
@@ -14,10 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { ChapterPlan, ProjectState, StageInfo } from './protocol.ts'
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
export type BookStageId = StageInfo['id']
|
|
19
|
-
|
|
20
|
-
/** 一个阶段契约。 */
|
|
17
|
+
/** 一个阶段契约(assistant 侧需要作为类型使用)。 */
|
|
21
18
|
export type BookStage = StageInfo
|
|
22
19
|
|
|
23
20
|
/** 大纲视为「已开书」的最短长度。 */
|
package/src/review-policy.ts
DELETED
|
@@ -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
|
-
}
|