dsh-novel-beat 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -30
- package/client.js +184 -0
- package/cordis.patch.yml +2 -0
- package/index.js +147 -448
- package/lib/compile.js +188 -0
- package/lib/remote-contract.js +65 -0
- package/lib/remote.js +102 -0
- package/lib/schema.js +480 -0
- package/lib/store.js +275 -0
- package/lib/tools.js +856 -0
- package/lib/verify.js +141 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +37 -3
package/README.md
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
# dsh-novel-beat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 每一处关键,都由你拍板。
|
|
4
|
+
>
|
|
5
|
+
> 更好的创作。
|
|
6
|
+
|
|
7
|
+
你写了一句「林砚在拍卖会拍下玉佩」。
|
|
8
|
+
模型交回来三千字:三个仇家、一个旧识、一段身世。
|
|
9
|
+
剧情是往前走,但走向不是你选的。
|
|
10
|
+
|
|
11
|
+
dsh-novel-beat 在正文之前,把「会改变故事走向」的节点拦下来:
|
|
12
|
+
每个节点给 2–4 个真分叉,选哪个、代价是什么,你定。
|
|
13
|
+
拍板写进全书账本;下一章写之前,账本先摆出来。
|
|
14
|
+
写完还能验:该发生的变化有没有发生,该还的债有没有还。
|
|
15
|
+
|
|
16
|
+
**不依赖 `novel-forge` 或任何其他插件。默认关闭,不写小说时不占地方。**
|
|
4
17
|
|
|
5
18
|
## 它解决什么问题
|
|
6
19
|
|
|
7
20
|
用 DSH 写小说时,模型在「细纲 → 正文」之间有一大片自由发挥的空间:细纲只写「林砚在拍卖会拍下玉佩」,怎么加价、谁在场、旧敌什么反应、玉佩什么来历,全由模型自己编。
|
|
8
21
|
|
|
9
|
-
|
|
22
|
+
本插件把粒度下沉到**决策点**,在生成正文之前就问;拍板之后不是只留一句话,而是写成**结构化状态变更**,进入全书账本。
|
|
10
23
|
|
|
11
|
-
|
|
24
|
+
## 为什么不是细纲
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
细纲回答「这一章大概发生什么」;拍板回答「这个节点现在必须选哪一个」。
|
|
27
|
+
|
|
28
|
+
细纲是给人看的;拍板是给下一章、给全书账本、给写后校验用的一份状态。选完不是记一句话,是 canon 里多了一条「谁/什么 从 A 变成 B」。
|
|
29
|
+
|
|
30
|
+
## 唯一判定规则
|
|
14
31
|
|
|
15
32
|
> **会改变故事状态的,都要问;只描述世界的,不用问。**
|
|
16
33
|
|
|
17
|
-
|
|
34
|
+
能往 canon 里写一条的,就是决策点:
|
|
18
35
|
|
|
19
36
|
| 类型 | 例子 |
|
|
20
37
|
|---|---|
|
|
21
38
|
| 动作 | 他动手了 / 他忍住了 |
|
|
22
|
-
| 知情 | 谁知道 / 谁不知道 |
|
|
39
|
+
| 知情 | 谁知道 / 谁不知道 / 揭露到什么程度 |
|
|
23
40
|
| 关系 | 结盟 / 翻脸 / 欠人情 |
|
|
24
41
|
| 得失 | 钱、物、功法、证据 |
|
|
25
42
|
| 状态 | 在哪、伤没伤、几层 |
|
|
@@ -29,46 +46,111 @@ novel-forge 的门禁是「整章细纲 批/不批」——粒度是一章,批
|
|
|
29
46
|
|
|
30
47
|
风、光、温度、比喻、氛围 → 不进清单,模型自己决定。
|
|
31
48
|
|
|
32
|
-
##
|
|
49
|
+
## 七个工具
|
|
33
50
|
|
|
34
51
|
| 工具 | 作用 |
|
|
35
52
|
|---|---|
|
|
36
|
-
| `beat_plan` |
|
|
37
|
-
| `beat_list` |
|
|
38
|
-
| `beat_decide` |
|
|
39
|
-
| `beat_gate` |
|
|
40
|
-
| `beat_compile` |
|
|
53
|
+
| `beat_plan` | 登记本章决策点;每个 2–4 个真分叉,每个选项必须有 `changes`(entity / key / from / to / evidence),不同选项的 changes 必须不同 |
|
|
54
|
+
| `beat_list` | 不传 `chapter` 看全书概览;传 `chapter` 看本章;`context:true` 返回 canon + 未回收义务 + 最近决策 |
|
|
55
|
+
| `beat_decide` | 记录作者选择;预设选项直接快照 changes,自定义选项必须带 changes;改主意用 `supersede:true` + `reason` |
|
|
56
|
+
| `beat_gate` | 写正文前体检;还有未拍板就 `pass:false` |
|
|
57
|
+
| `beat_compile` | 产出本章约束(默认 `format:'self'`):必须发生、证据、不得出现、既有义务、全书 canon;`format:'novel-forge'` 只做可选兼容导出 |
|
|
58
|
+
| `beat_verify` | 写后校验:`text` 或 `path` 二选一;缺变化 / 踩禁项 / 到期义务没回应,都有明确 failure/warning |
|
|
59
|
+
| `beat_adjust` | 作者主权动作:`waive` 义务、`supersede` 决策;必须写 `reason` |
|
|
60
|
+
|
|
61
|
+
## 会话开关(默认关)
|
|
62
|
+
|
|
63
|
+
默认 `off`:装上不主动占 schema,作者开启后才把 `beat_*` 加进当前会话。
|
|
64
|
+
|
|
65
|
+
- `/beat on`、`/beat off`、`/beat status`
|
|
66
|
+
- composer 左侧「拍板」按钮(Typert remote,直接调 host 的 `beatMode`)
|
|
67
|
+
- 关掉只影响当前会话,数据保留;同一 session resume 保持;fork 的新会话默认 `off`
|
|
68
|
+
|
|
69
|
+
## 一次推进
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
beat_list(context:true) # 看 canon + 未回收义务
|
|
73
|
+
→ beat_plan(...) # 登记 2–4 个真分叉
|
|
74
|
+
→ ask_user_question # 作者拍板
|
|
75
|
+
→ beat_decide(...) × N # 写 chosen / canon / obligations
|
|
76
|
+
→ beat_gate(...) # 必须 pass
|
|
77
|
+
→ beat_compile(...) # 自包含契约
|
|
78
|
+
→ 写正文(宿主模型;插件不管正文)
|
|
79
|
+
→ beat_verify(text/path) # 缺变化 / 禁项 / 义务到期
|
|
80
|
+
→ 不通过:改稿重验;通过:本章 verified,下一章从 canon 继续
|
|
81
|
+
```
|
|
41
82
|
|
|
42
|
-
|
|
83
|
+
作者改主意:
|
|
43
84
|
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
beat_decide
|
|
48
|
-
↓
|
|
49
|
-
beat_gate → PASS
|
|
50
|
-
↓
|
|
51
|
-
beat_compile → 细纲片段
|
|
52
|
-
↓
|
|
53
|
-
novel_outline save_chapter + approve
|
|
54
|
-
↓
|
|
55
|
-
novel_write_chapter
|
|
85
|
+
```text
|
|
86
|
+
beat_adjust(waive, ob1, "这条伏笔不要了")
|
|
87
|
+
beat_adjust(supersede, b3-2, "这个走向作废")
|
|
88
|
+
beat_decide(supersede:true, reason:"…") # 重拍当前决策
|
|
56
89
|
```
|
|
57
90
|
|
|
58
91
|
## 数据落盘
|
|
59
92
|
|
|
60
|
-
```
|
|
61
|
-
<工作区>/.dsh-novel-beat/<书名>/
|
|
93
|
+
```text
|
|
94
|
+
<工作区>/.dsh-novel-beat/<书名>/book.json # 单文件账本
|
|
95
|
+
<工作区>/.dsh-novel-beat/_sessions.json # 会话开关
|
|
96
|
+
<工作区>/.dsh-novel-beat/<书名>/legacy/ # 0.5 旧数据备份
|
|
62
97
|
```
|
|
63
98
|
|
|
64
|
-
|
|
99
|
+
`book.json` 包含 `decisions`、`canon`、`obligations`、`chapters`、`audit`;一次读-改-写,tmp → `.bak` → rename 原子落盘,per-book 队列串行化,避免并发写坏。
|
|
65
100
|
|
|
66
|
-
|
|
101
|
+
0.5 的 `chNNN.json` 首次访问时自动迁移:旧拍板保留为 `legacy` 决策,但没有结构化 changes/canon;`beat_verify` 对 legacy 只做弱校验。旧文件不删除,移到 `legacy/`。
|
|
67
102
|
|
|
68
103
|
## 安装
|
|
69
104
|
|
|
70
105
|
```powershell
|
|
71
106
|
# 改完代码要「先卸再装」才会重载
|
|
107
|
+
plugin_manager set_bundle target=dsh-novel-beat enabled=false
|
|
72
108
|
plugin_manager remove_bundle target=dsh-novel-beat
|
|
73
|
-
plugin_manager install_bundle target=D:\插件\plugins\novel-beat
|
|
109
|
+
plugin_manager install_bundle target=link:D:\插件\plugins\novel-beat
|
|
74
110
|
```
|
|
111
|
+
|
|
112
|
+
装好后默认关闭;在 composer 左侧点「拍板」或输入 `/beat on`。Host 与 Client 改动后需要重启 DSH 才会加载新的 JS 生成。
|
|
113
|
+
|
|
114
|
+
## 0.5 → 0.6 的破坏性变更
|
|
115
|
+
|
|
116
|
+
- 数据从「每章一个 `chNNN.json`」变为「每书一个 `book.json`」;自动迁移,旧文件进 `legacy/`。
|
|
117
|
+
- `beat_plan` 的每个选项现在必须带 `changes`;1 个选项或换措辞的假分叉会被拒绝。
|
|
118
|
+
- `beat_compile` 默认输出 `self` 契约;旧的 `## 本章必写场景` 只在 `format:'novel-forge'` 时输出,并带 deprecated warning。
|
|
119
|
+
- `beat_list` 的 `chapter` 变为可选;不传时返回全书概览。
|
|
120
|
+
- `novel-forge` 从「工作流依赖」降为「可选导出格式」。
|
|
121
|
+
|
|
122
|
+
## 边界(写清楚,避免误会)
|
|
123
|
+
|
|
124
|
+
- `beat_verify` 是**确定性结构校验**,不是 AI 语义审稿:它查证据短语、禁项、到期义务,不判断文笔和潜台词。
|
|
125
|
+
- `path` 只读工作区内 `.md`/`.txt`,≤ 2 MB;`text` 直接传。
|
|
126
|
+
- 插件不接管正文、不写章节文件;正文由宿主模型写,作者自己保存。
|
|
127
|
+
|
|
128
|
+
## 适合谁
|
|
129
|
+
|
|
130
|
+
- 适合:写长篇、边写边推、怕设定崩、想让剧情由自己掌握的作者。
|
|
131
|
+
- 不适合:只想让模型一口气写完、不打算回头改的人。
|
|
132
|
+
|
|
133
|
+
## 开发
|
|
134
|
+
|
|
135
|
+
```powershell
|
|
136
|
+
D:\node.exe --test # 59 tests
|
|
137
|
+
D:\node.exe --check index.js client.js lib/*.js
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
index.js # host 入口:7 工具 + /beat + skill + 会话 restrict
|
|
142
|
+
lib/schema.js # 校验/规范化/canon/义务/id/legacy
|
|
143
|
+
lib/store.js # book.json 原子写、锁、迁移、会话模式
|
|
144
|
+
lib/compile.js # self / novel-forge 契约
|
|
145
|
+
lib/verify.js # 确定性校验
|
|
146
|
+
lib/tools.js # 7 工具 handler 与 schema
|
|
147
|
+
lib/remote-contract.js # Typert remote 契约(beatMode)
|
|
148
|
+
lib/remote.js # BeatRemote:status/setMode,复用 /beat 命令
|
|
149
|
+
client.js # composer「拍板」按钮
|
|
150
|
+
cordis.patch.yml # 主插件 + remote 两行
|
|
151
|
+
test/*.test.mjs # schema/store/compile/verify/tools/index/e2e/client
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
package/client.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-novel-beat',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
|
|
7
|
+
// Keep this contract in sync with lib/remote-contract.js. It is duplicated
|
|
8
|
+
// because this browser factory cannot import a relative Host module.
|
|
9
|
+
function scalar(validate) {
|
|
10
|
+
return {
|
|
11
|
+
parse(value) {
|
|
12
|
+
validate(value)
|
|
13
|
+
return value
|
|
14
|
+
},
|
|
15
|
+
safeParse(value) {
|
|
16
|
+
try {
|
|
17
|
+
validate(value)
|
|
18
|
+
return { success: true, data: value }
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return { success: false, error }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const sessionIdSchema = scalar((value) => {
|
|
26
|
+
if (typeof value !== 'string' || value.length === 0) throw new Error('sessionId must be a non-empty string')
|
|
27
|
+
})
|
|
28
|
+
const booleanSchema = scalar((value) => {
|
|
29
|
+
if (typeof value !== 'boolean') throw new Error('on must be a boolean')
|
|
30
|
+
})
|
|
31
|
+
const modeResultSchema = scalar((value) => {
|
|
32
|
+
if (!value || typeof value.on !== 'boolean') throw new Error('result must be { on: boolean }')
|
|
33
|
+
})
|
|
34
|
+
const codec = (typeSymbol, schema) => ({ mode: 'strict', typeSymbol, create: () => schema })
|
|
35
|
+
const param = (name, schema) => ({ name, wire: name, source: 'json', codec: codec(name, schema) })
|
|
36
|
+
const TYPERT_REMOTE = {
|
|
37
|
+
package: 'dsh-novel-beat',
|
|
38
|
+
descriptors: [
|
|
39
|
+
{
|
|
40
|
+
id: 'dsh-novel-beat#beatMode/status', namespace: 'beatMode', service: 'beatMode', method: 'status',
|
|
41
|
+
invocation: { kind: 'direct' }, parameters: [param('sessionId', sessionIdSchema)],
|
|
42
|
+
result: codec('BeatMode', modeResultSchema),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'dsh-novel-beat#beatMode/setMode', namespace: 'beatMode', service: 'beatMode', method: 'setMode',
|
|
46
|
+
invocation: { kind: 'direct' }, parameters: [param('sessionId', sessionIdSchema), param('on', booleanSchema)],
|
|
47
|
+
result: codec('BeatMode', modeResultSchema),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sessionIdOf(value) {
|
|
53
|
+
if (typeof value === 'string') return value
|
|
54
|
+
if (!value) return null
|
|
55
|
+
return value.id ?? value.sessionId ?? String(value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function BeatModeButton({ sessionId, remote }) {
|
|
59
|
+
const sid = sessionIdOf(sessionId)
|
|
60
|
+
const [state, setState] = React.useState({ on: null, busy: false, error: null })
|
|
61
|
+
const busyRef = React.useRef(false)
|
|
62
|
+
const seq = React.useRef(0)
|
|
63
|
+
const lastSid = React.useRef(null)
|
|
64
|
+
|
|
65
|
+
const call = async (action) => {
|
|
66
|
+
if (!sid) throw new Error('拿不到当前会话')
|
|
67
|
+
if (!remote || typeof remote[action === 'status' ? 'status' : 'setMode'] !== 'function') {
|
|
68
|
+
throw new Error('remote 未就绪')
|
|
69
|
+
}
|
|
70
|
+
const result = action === 'status'
|
|
71
|
+
? await remote.status(sid)
|
|
72
|
+
: await remote.setMode(sid, action === 'on')
|
|
73
|
+
if (result && result.ok === false) {
|
|
74
|
+
throw new Error(result.error?.message ?? 'remote 调用失败')
|
|
75
|
+
}
|
|
76
|
+
const value = result && result.ok === true ? result.value : result
|
|
77
|
+
if (!value || typeof value.on !== 'boolean') throw new Error('remote 回执缺 on')
|
|
78
|
+
return value.on
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const run = async (action, silent) => {
|
|
82
|
+
if (busyRef.current) return
|
|
83
|
+
const my = ++seq.current
|
|
84
|
+
if (!silent) {
|
|
85
|
+
busyRef.current = true
|
|
86
|
+
setState((prev) => ({ ...prev, busy: true, error: null }))
|
|
87
|
+
}
|
|
88
|
+
const reset = setTimeout(() => {
|
|
89
|
+
if (my !== seq.current) return
|
|
90
|
+
busyRef.current = false
|
|
91
|
+
setState((prev) => silent
|
|
92
|
+
? { ...prev, busy: false }
|
|
93
|
+
: { ...prev, busy: false, error: prev.error ?? '命令超时' })
|
|
94
|
+
}, 2500)
|
|
95
|
+
try {
|
|
96
|
+
const on = await call(action)
|
|
97
|
+
if (my !== seq.current) return
|
|
98
|
+
busyRef.current = false
|
|
99
|
+
setState({ on, busy: false, error: null })
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (my !== seq.current) return
|
|
102
|
+
busyRef.current = false
|
|
103
|
+
const message = String(error?.message ?? error)
|
|
104
|
+
if (!silent) console.error('[novel-beat] 切换失败', message)
|
|
105
|
+
setState((prev) => silent
|
|
106
|
+
? { ...prev, busy: false, error: null }
|
|
107
|
+
: { ...prev, busy: false, error: message })
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(reset)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
if (!sid || lastSid.current === sid) return
|
|
115
|
+
lastSid.current = sid
|
|
116
|
+
run('status', true)
|
|
117
|
+
}, [sid])
|
|
118
|
+
|
|
119
|
+
const short = state.error ? String(state.error).slice(0, 6) : ''
|
|
120
|
+
const label = state.on === true
|
|
121
|
+
? '拍板:开'
|
|
122
|
+
: state.on === false
|
|
123
|
+
? '拍板:关'
|
|
124
|
+
: state.error
|
|
125
|
+
? `拍板!${short}`
|
|
126
|
+
: '拍板'
|
|
127
|
+
const style = {
|
|
128
|
+
height: 24,
|
|
129
|
+
marginRight: 6,
|
|
130
|
+
padding: '0 8px',
|
|
131
|
+
borderRadius: 6,
|
|
132
|
+
border: '1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.35))',
|
|
133
|
+
background: state.on ? 'var(--dsw-alias-bg-accent, #2563eb)' : 'transparent',
|
|
134
|
+
color: state.on ? 'var(--dsw-alias-text-on-accent, #fff)' : 'var(--dsw-alias-text-secondary, inherit)',
|
|
135
|
+
fontSize: 12,
|
|
136
|
+
lineHeight: '22px',
|
|
137
|
+
cursor: 'pointer',
|
|
138
|
+
opacity: state.busy ? 0.65 : 1,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return h('button', {
|
|
142
|
+
type: 'button',
|
|
143
|
+
style,
|
|
144
|
+
title: state.error ?? '切换拍板模式(/beat on | /beat off)',
|
|
145
|
+
'aria-pressed': state.on === true,
|
|
146
|
+
'aria-busy': state.busy,
|
|
147
|
+
'aria-label': state.error ? `${label}:${state.error}` : label,
|
|
148
|
+
onClick: () => {
|
|
149
|
+
if (busyRef.current) return
|
|
150
|
+
run(state.on ? 'off' : 'on', false)
|
|
151
|
+
},
|
|
152
|
+
}, state.busy ? '…' : label)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
inject: ['slots', 'remote'],
|
|
157
|
+
async apply(ctx) {
|
|
158
|
+
let remote = null
|
|
159
|
+
let dispose = null
|
|
160
|
+
try {
|
|
161
|
+
// $mount() is async and returns a disposer; the callable service is
|
|
162
|
+
// ctx.get('remote.<service>'), exactly like PUA's remote.puaConfig.
|
|
163
|
+
dispose = await ctx.remote.$mount(TYPERT_REMOTE)
|
|
164
|
+
remote = ctx.get('remote.beatMode') ?? null
|
|
165
|
+
if (!remote) console.error('[novel-beat] remote.beatMode 未注册')
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error('[novel-beat] remote mount 失败', error)
|
|
168
|
+
}
|
|
169
|
+
if (typeof dispose === 'function') {
|
|
170
|
+
ctx.effect(() => () => {
|
|
171
|
+
try { dispose() } catch {}
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register(
|
|
175
|
+
{ name: 'conversation.input.left', id: 'dsh-novel-beat-mode', order: 20, label: '拍板' },
|
|
176
|
+
(props) => h(BeatModeButton, {
|
|
177
|
+
sessionId: props?.sessionId ?? props?.session?.sessionId,
|
|
178
|
+
remote,
|
|
179
|
+
}),
|
|
180
|
+
))
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
})
|