dsh-maoying 0.1.11 → 0.1.13
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.en.md +169 -0
- package/README.md +17 -2
- package/bin/maoying.mjs +95 -50
- package/docs/creator-panel.md +33 -7
- package/docs/design.md +10 -7
- package/docs/spec.md +7 -4
- package/index.mjs +242 -88
- package/lib/base.css +156 -25
- package/lib/client.js +688 -232
- package/lib/epub.mjs +37 -6
- package/lib/novel-parse.mjs +171 -46
- package/lib/panel-core.mjs +116 -10
- package/lib/version.mjs +122 -36
- package/lib/yaml-loader.mjs +10 -2
- package/package.json +7 -2
- package/presets/maoying/agent.cordis.v2.yml +6 -1
- package/presets/maoying/agent.cordis.yml +5 -2
- package/presets/maoying/restrict.mjs +5 -6
- package/skills/maoying-review-checklist/SKILL.md +1 -1
- package/templates/novel/README.md +3 -3
- package/templates/novel/foreshadows.yml +1 -0
- package/templates/novel/timeline.yml +1 -1
package/lib/panel-core.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* 绝不让它炸掉整个快照。
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
31
|
+
import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
32
32
|
import path from 'node:path'
|
|
33
33
|
|
|
34
34
|
import { buildIndexYaml, chapterSortKey, collectChapters, countCjk, parseFlatYamlArray, parseScalarText, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
|
|
@@ -176,6 +176,22 @@ function joinLines(lines, originalText) {
|
|
|
176
176
|
return lines.join(eol)
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* 原子落盘:先写同目录临时文件,再 renameSync 原子替换——truncate+write
|
|
181
|
+
* 直写期间并发读会看到半截文件,rename 在同一文件系统上不存在中间态。
|
|
182
|
+
* 临时文件藏在同目录(保证 rename 跨设备语义成立),失败时扫尾后原样上抛。
|
|
183
|
+
*/
|
|
184
|
+
function writeAtomic(target, data) {
|
|
185
|
+
const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`)
|
|
186
|
+
try {
|
|
187
|
+
writeFileSync(tmp, data, 'utf8')
|
|
188
|
+
renameSync(tmp, target)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
try { unlinkSync(tmp) } catch { /* 临时文件没立起来就没什么可扫 */ }
|
|
191
|
+
throw err
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
179
195
|
// ── frontmatter 解析(yaml 可用走真解析,否则正则平键 + 条目块)───────────
|
|
180
196
|
|
|
181
197
|
/**
|
|
@@ -184,7 +200,7 @@ function joinLines(lines, originalText) {
|
|
|
184
200
|
* 一层列表);规范禁止深嵌套,超出形状的行原样忽略。
|
|
185
201
|
*/
|
|
186
202
|
function parseFrontmatterFallback(raw) {
|
|
187
|
-
const meta =
|
|
203
|
+
const meta = Object.create(null) // 无原型对象:__proto__ 键只进自身属性,改写不了原型
|
|
188
204
|
let listKey = null
|
|
189
205
|
for (const line of raw.split(/\r?\n/)) {
|
|
190
206
|
const clean = stripInlineComment(line).replace(/\s+$/, '')
|
|
@@ -376,6 +392,7 @@ function readProject(novelDir, yamlLib, warnings) {
|
|
|
376
392
|
length: strOrNull(data?.length),
|
|
377
393
|
language: strOrNull(data?.language),
|
|
378
394
|
phase: strOrNull(data?.phase),
|
|
395
|
+
mode: strOrNull(data?.mode),
|
|
379
396
|
description: strOrNull(data?.description),
|
|
380
397
|
}
|
|
381
398
|
}
|
|
@@ -565,6 +582,20 @@ export function readChapter(novelDir, file, options = {}) {
|
|
|
565
582
|
|
|
566
583
|
// ── 写操作:行级原地替换 ────────────────────────────────────────────────────
|
|
567
584
|
|
|
585
|
+
/**
|
|
586
|
+
* 保注释赋值刀:把一行 `缩进key: 旧值 # 行尾注释` 的值换成 newValue,行尾
|
|
587
|
+
* 注释(连同值与注释间的空白)原样保留。值/注释切分复用 novel-parse 的
|
|
588
|
+
* stripInlineComment——引号内的 # 不算注释(`长#夜` 是合法标量),避开
|
|
589
|
+
* `(.*?)` 在值内第一个 # 提前截断、同值连存两次产出非法 YAML 的刀法损坏。
|
|
590
|
+
* 行须命中 headRe(捕获组 1 = `缩进key:`),未命中返回 null 由调用方兜底。
|
|
591
|
+
*/
|
|
592
|
+
function rewriteValueKeepComment(line, headRe, newValue) {
|
|
593
|
+
const body = stripInlineComment(line).replace(/\s+$/, '')
|
|
594
|
+
const head = headRe.exec(body)
|
|
595
|
+
if (head === null) return null
|
|
596
|
+
return `${head[1]} ${newValue}${line.slice(body.length)}`
|
|
597
|
+
}
|
|
598
|
+
|
|
568
599
|
/**
|
|
569
600
|
* 章节状态流转:只动 frontmatter 围栏内的 status 行,其余字节原样保留。
|
|
570
601
|
* status 出枚举 422;文件不存在 404;无围栏 / status 行缺失或多处 409 拒写。
|
|
@@ -575,11 +606,15 @@ export function mutateChapterStatus(novelDir, file, status) {
|
|
|
575
606
|
const text = readTarget(target, `章节 ${name}`)
|
|
576
607
|
const fence = splitFrontmatter(text)
|
|
577
608
|
if (fence.raw === null) fail(409, `${name} 没有 frontmatter 围栏,无处替换 status 行`)
|
|
578
|
-
const replaced = replaceLineOnce(
|
|
579
|
-
|
|
580
|
-
|
|
609
|
+
const replaced = replaceLineOnce(
|
|
610
|
+
fence.raw,
|
|
611
|
+
/^status:[^\n]*?$/,
|
|
612
|
+
// 保注释刀法:只换值,用户写在行尾的注释原样保留(与 mutatePhase 一致)
|
|
613
|
+
(line) => rewriteValueKeepComment(line, /^(status:)/, status) ?? line,
|
|
614
|
+
{ what: `${name} frontmatter 的 status 行` },
|
|
615
|
+
)
|
|
581
616
|
const next = text.slice(0, fence.index) + replaced + text.slice(fence.index + fence.raw.length)
|
|
582
|
-
|
|
617
|
+
writeAtomic(target, next)
|
|
583
618
|
return { ok: true, file: name, status }
|
|
584
619
|
}
|
|
585
620
|
|
|
@@ -617,8 +652,9 @@ export function mutateForeshadowStatus(novelDir, id, status) {
|
|
|
617
652
|
}
|
|
618
653
|
}
|
|
619
654
|
if (hits !== 1) fail(409, `foreshadows.yml 条目 ${id} 的 status 行应恰有一处,实得 ${hits} 处——已拒绝写入`)
|
|
620
|
-
|
|
621
|
-
|
|
655
|
+
// 保注释刀法:只换值,行尾注释原样保留(headRe 弱于上方计数正则,兜底仅为防御)
|
|
656
|
+
lines[hit] = rewriteValueKeepComment(lines[hit], /^([ \t]*status:)/, status) ?? lines[hit]
|
|
657
|
+
writeAtomic(target, joinLines(lines, text))
|
|
622
658
|
return { ok: true, id, status }
|
|
623
659
|
}
|
|
624
660
|
|
|
@@ -636,10 +672,80 @@ export function mutatePhase(novelDir, phase) {
|
|
|
636
672
|
const next = text.replace(new RegExp(phaseRe.source, 'm'), (line) =>
|
|
637
673
|
line.replace(/^(phase:[ \t]*)(.*?)([ \t]*(?:#.*)?)$/, (_m, key, _value, comment) => `${key}${phase}${comment ?? ''}`),
|
|
638
674
|
)
|
|
639
|
-
|
|
675
|
+
writeAtomic(target, next)
|
|
640
676
|
return { ok: true, phase }
|
|
641
677
|
}
|
|
642
678
|
|
|
679
|
+
const PROJECT_MODE_KEYS = ['ai-draft', 'co-write', 'author-draft']
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 小说设置:project.yml 平键批量改写(创作台「小说设置」tab)。逐键做
|
|
683
|
+
* 「单行值替换、行尾注释原样保留」(与 mutatePhase 同一保注释刀法);文件里
|
|
684
|
+
* 没有的键按模板顺序补在末尾(带模板注释)。校验:phase/mode 越枚举 422、
|
|
685
|
+
* title 空白 422。值统一经 yamlScalar 规范成可回读的 YAML 标量。
|
|
686
|
+
*/
|
|
687
|
+
export function mutateProject(novelDir, values) {
|
|
688
|
+
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
|
689
|
+
fail(422, 'values 必须是键值对象')
|
|
690
|
+
}
|
|
691
|
+
const phase = typeof values.phase === 'string' ? values.phase : ''
|
|
692
|
+
if (!PHASE_KEYS.includes(phase)) fail(422, `phase 非法「${phase}」——七态只认 ${PHASE_KEYS.join(' / ')}`)
|
|
693
|
+
const mode = typeof values.mode === 'string' ? values.mode : ''
|
|
694
|
+
if (!PROJECT_MODE_KEYS.includes(mode)) fail(422, `mode 非法「${mode}」——只认 ${PROJECT_MODE_KEYS.join(' / ')}`)
|
|
695
|
+
const title = typeof values.title === 'string' ? values.title.trim() : ''
|
|
696
|
+
if (title === '') fail(422, 'title 不能为空——书稿总得有个名字')
|
|
697
|
+
const language = typeof values.language === 'string' && values.language.trim() !== '' ? values.language.trim() : 'zh'
|
|
698
|
+
const textFields = ['author', 'genre', 'length', 'description']
|
|
699
|
+
const next = { title, author: '', genre: '', length: '', language, phase, mode, description: '' }
|
|
700
|
+
for (const k of textFields) next[k] = typeof values[k] === 'string' ? values[k].trim() : ''
|
|
701
|
+
|
|
702
|
+
const target = path.join(novelDir, 'project.yml')
|
|
703
|
+
const text = readTarget(target, 'project.yml')
|
|
704
|
+
const lines = text.split(/\r?\n/)
|
|
705
|
+
const pending = new Map(Object.entries(next))
|
|
706
|
+
for (let i = 0; i < lines.length; i++) {
|
|
707
|
+
const m = /^([ \t]*)([A-Za-z][A-Za-z0-9_-]*):/.exec(lines[i])
|
|
708
|
+
if (!m || !pending.has(m[2])) continue
|
|
709
|
+
const [, indent, key] = m
|
|
710
|
+
// 保注释刀法:值/注释切分走 stripInlineComment(引号内 # 不算注释),
|
|
711
|
+
// 注释原样保留在行尾;键行形状异常时原样保留,宁缺毋写坏
|
|
712
|
+
lines[i] = rewriteValueKeepComment(lines[i], /^([ \t]*[A-Za-z][A-Za-z0-9_-]*:)/, yamlScalar(next[key])) ?? lines[i]
|
|
713
|
+
pending.delete(key)
|
|
714
|
+
}
|
|
715
|
+
// 模板补齐顺序:缺失键插在首个非注释内容行前,保持可读
|
|
716
|
+
if (pending.size > 0) {
|
|
717
|
+
const header = ['# project.yml —— 书稿元信息(平键,无嵌套)。phase 是七阶段工作流的状态机指针,', '# 每完成一个里程碑由主编推进并简报用户;mode 决定正文归谁写。']
|
|
718
|
+
const missing = [...pending.entries()].map(([k, v]) => `${k}: ${yamlScalar(v)}`)
|
|
719
|
+
let insertAt = lines.findIndex((l) => l.trim() !== '' && !l.trim().startsWith('#'))
|
|
720
|
+
if (insertAt === -1) insertAt = lines.length
|
|
721
|
+
if (lines.every((l) => l.trim() === '' || l.trim().startsWith('#'))) {
|
|
722
|
+
lines.splice(insertAt, 0, ...header, ...missing)
|
|
723
|
+
} else {
|
|
724
|
+
lines.splice(insertAt, 0, ...missing)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
writeAtomic(target, joinLines(lines, text))
|
|
728
|
+
return { ok: true }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** YAML 平标量:安全字符裸写,含特殊字符(冒号空格/井号/引号/首尾空格等)
|
|
732
|
+
* 时双引号包裹并转义;空串写成 ""。 */
|
|
733
|
+
function yamlScalar(v) {
|
|
734
|
+
const s = String(v ?? '')
|
|
735
|
+
if (s === '') return '""'
|
|
736
|
+
// 类型漂移防护:形如布尔/数字/null 的字符串(《2046》这类书名)裸写会被
|
|
737
|
+
// YAML 回读成 number/boolean/null,strOrNull 置 null 后面板显示「—」——
|
|
738
|
+
// 一律双引号包裹,保住「回读仍是原字符串」(与 novel-parse 的 flowScalar 同口径)。
|
|
739
|
+
if (/^(true|false|null|~|yes|no|on|off|-?\d+|-?\d*\.\d+)$/i.test(s)) {
|
|
740
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
|
|
741
|
+
}
|
|
742
|
+
if (/^[\w\u4e00-\u9fff\u3000-\u303f\uff00-\uffef.,;:!?()()【】《》「」『』·、—…·//%+×\- ]+$/.test(s)
|
|
743
|
+
&& !/(^|[\s:])#/.test(s) && !/:\s/.test(s) && !/^[-?:,\[\]{}#&*!|>'"%@`]/.test(s) && !/\s$/.test(s)) {
|
|
744
|
+
return s
|
|
745
|
+
}
|
|
746
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
|
|
747
|
+
}
|
|
748
|
+
|
|
643
749
|
/**
|
|
644
750
|
* 重建 index.yml(与 CLI `maoying index` 同一实现):chapters/ 缺失 404;
|
|
645
751
|
* 成功返回章节数。
|
|
@@ -655,6 +761,6 @@ export function rebuildIndex(novelDir) {
|
|
|
655
761
|
volumesRaw = null // volumes.yml 缺失/读不动:按无分卷重建,章节照收
|
|
656
762
|
}
|
|
657
763
|
const { text, chaptersCount } = buildIndexYaml(chapters, volumesRaw)
|
|
658
|
-
|
|
764
|
+
writeAtomic(path.join(novelDir, 'index.yml'), text)
|
|
659
765
|
return { ok: true, chapters: chaptersCount }
|
|
660
766
|
}
|
package/lib/version.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 版本提示 —— 检测线上是否有比本地更新的版本(创作台面板用)。
|
|
3
3
|
*
|
|
4
|
-
* 同构移植自 dsh-paoding tools/lib/version.mjs
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 同构移植自 dsh-paoding tools/lib/version.mjs(用户指定模仿其机制)。检测
|
|
5
|
+
* 源三级:npm registry 与 npmmirror 镜像两路并行竞速(manifest 形状与数据
|
|
6
|
+
* 一致,先成功者胜,官方源超时 / 被墙时国内几秒内可达的镜像顶上,分钟级
|
|
7
|
+
* 同步延迟对「有没有新版」这个判断无伤大雅);两路 npm 源皆败再串行走
|
|
8
|
+
* GitHub releases/latest 兜底(独立数据源)。
|
|
8
9
|
*
|
|
9
10
|
* 零依赖纯 Node(>=18):不引 cordis / npm 依赖,fetch 可注入(缺省用
|
|
10
11
|
* globalThis.fetch),超时用 AbortSignal.timeout()。所有失败路径都不抛错,
|
|
@@ -57,25 +58,47 @@ export async function currentVersion() {
|
|
|
57
58
|
return { version: FALLBACK_VERSION, source: 'fallback' }
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* 拆版本号:剥前缀 v/V,按 '.' 切段,`-` 后为预发布后缀(原样保留)。核心段
|
|
63
|
+
* 必须纯数字才转数值,非纯数字段(如 '12abc'、空串)一律按 0——parseInt 的
|
|
64
|
+
* 前缀解析会把 '12abc' 当成 12,这里显式拒绝。
|
|
65
|
+
*/
|
|
61
66
|
function parseVersion(value) {
|
|
62
67
|
let s = String(value ?? '').trim()
|
|
63
68
|
if (s.startsWith('v') || s.startsWith('V')) s = s.slice(1)
|
|
64
69
|
const dash = s.indexOf('-')
|
|
65
70
|
const coreText = dash === -1 ? s : s.slice(0, dash)
|
|
66
71
|
const pre = dash === -1 ? '' : s.slice(dash + 1)
|
|
67
|
-
const core = coreText.split('.').map((n) =>
|
|
68
|
-
const x = parseInt(n, 10)
|
|
69
|
-
return Number.isFinite(x) ? x : 0
|
|
70
|
-
})
|
|
72
|
+
const core = coreText.split('.').map((n) => (/^\d+$/.test(n) ? Number(n) : 0))
|
|
71
73
|
return { core, pre }
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/**
|
|
77
|
+
* 预发布段逐段比较:按 '.' 切段,纯数字段转数值比(rc.10 必须大于 rc.2,
|
|
78
|
+
* 字符串序会误判 'rc.10' < 'rc.2'),非数字段按字典序;段数不齐缺段视为
|
|
79
|
+
* 空段,空段小于非空段(semver 口径:rc.1 < rc.1.0)。
|
|
80
|
+
*/
|
|
81
|
+
function comparePre(a, b) {
|
|
82
|
+
const sa = a.split('.')
|
|
83
|
+
const sb = b.split('.')
|
|
84
|
+
const len = Math.max(sa.length, sb.length)
|
|
85
|
+
for (let i = 0; i < len; i++) {
|
|
86
|
+
const x = sa[i] ?? ''
|
|
87
|
+
const y = sb[i] ?? ''
|
|
88
|
+
if (x === y) continue
|
|
89
|
+
if (x === '') return -1 // 缺段(空)< 非空
|
|
90
|
+
if (y === '') return 1
|
|
91
|
+
if (/^\d+$/.test(x) && /^\d+$/.test(y)) return Number(x) < Number(y) ? -1 : 1
|
|
92
|
+
return x < y ? -1 : 1 // 非数字段(含数字段与非数字段混排)按字典序
|
|
93
|
+
}
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
/**
|
|
75
98
|
* 版本比较(可测纯函数):a < b 返回 -1,相等返回 0,a > b 返回 1。
|
|
76
99
|
* 简化语义(不追求完整 semver):剥前缀 v/V 后按 '.' 逐段数值比较,缺段补 0;
|
|
77
100
|
* 核心段相同时带预发布后缀(如 0.3.0-rc.1)视为低于无后缀同版本,两个都有
|
|
78
|
-
*
|
|
101
|
+
* 预发布后缀则按 '.' 切段逐段比较(数字段数值比、其余字典序,见 comparePre)。
|
|
79
102
|
*/
|
|
80
103
|
export function compareVersions(a, b) {
|
|
81
104
|
const pa = parseVersion(a)
|
|
@@ -89,7 +112,7 @@ export function compareVersions(a, b) {
|
|
|
89
112
|
if (pa.pre !== pb.pre) {
|
|
90
113
|
if (pa.pre === '') return 1 // 无后缀 > 有后缀
|
|
91
114
|
if (pb.pre === '') return -1
|
|
92
|
-
return pa.pre
|
|
115
|
+
return comparePre(pa.pre, pb.pre)
|
|
93
116
|
}
|
|
94
117
|
return 0
|
|
95
118
|
}
|
|
@@ -190,38 +213,67 @@ export async function fetchLatestFromMirror(opts) {
|
|
|
190
213
|
}
|
|
191
214
|
|
|
192
215
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean,
|
|
202
|
-
* releaseUrl: string|null, checkedAt: string, error: string|null,
|
|
203
|
-
* source: 'npm'|'mirror'|'github'|null}>}
|
|
216
|
+
* npm 双源并行竞速:registry 与 mirror 同时发起,谁先拿到有效版本(ok:true)
|
|
217
|
+
* 就用谁——两源 manifest 形状与数据一致(mirror 同步自官方源),成功语义
|
|
218
|
+
* 等价,先到先用不改变判定结果;单路超时同为 timeoutMs,npm 阶段最坏耗时
|
|
219
|
+
* 仍由 timeoutMs 封顶(原三级串行最坏 3×timeout 收敛为 npm 并行 1× +
|
|
220
|
+
* GitHub 串行 1×)。失败语义与旧串行链保持一致:registry 错误排前、mirror
|
|
221
|
+
* 排后(对应 failures[0] / failures[1]),两路皆败才由调用方串行走 GitHub
|
|
222
|
+
* 兜底。两路请求都提前挂好回调,先到即决出胜负,后到的结果作废但不产生
|
|
223
|
+
* 未处理异常。
|
|
204
224
|
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
225
|
+
function raceLatestFromNpm(opts) {
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
const errors = [null, null] // [registry 错误, mirror 错误],全败时透出排障
|
|
228
|
+
let settled = 0 // 已落定的路数
|
|
229
|
+
let winner = false // 是否已决出胜者
|
|
230
|
+
const watch = (promise, index) => {
|
|
231
|
+
promise.then(
|
|
232
|
+
(hit) => {
|
|
233
|
+
if (winner) return // 已有先到者,本路结果作废
|
|
234
|
+
if (hit && hit.ok) {
|
|
235
|
+
winner = true
|
|
236
|
+
resolve({ ok: true, hit })
|
|
237
|
+
} else {
|
|
238
|
+
errors[index] = hit?.error ?? '未知失败'
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
(err) => {
|
|
242
|
+
errors[index] = String(err?.message ?? err)
|
|
243
|
+
},
|
|
244
|
+
).finally(() => {
|
|
245
|
+
settled += 1
|
|
246
|
+
if (!winner && settled === 2) resolve({ ok: false, errors })
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
// registry 先挂监听:两路同步落定时胜者确定性偏向官方源
|
|
250
|
+
watch(fetchLatestFromRegistry(opts), 0)
|
|
251
|
+
watch(fetchLatestFromMirror(opts), 1)
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 实际执行一轮检测(缓存 TTL 与并发去重之外的检测主体,供 getVersionInfo
|
|
257
|
+
* 调度):npm registry 与 npmmirror 镜像并行竞速(见 raceLatestFromNpm),
|
|
258
|
+
* 两路皆败才串行请求 GitHub release 兜底;三路皆败按 lastGood / 降级收敛。
|
|
259
|
+
*/
|
|
260
|
+
async function detectLatest({ fetchImpl, timeoutMs, nowMs }) {
|
|
208
261
|
const { version: current, source: currentSource } = await currentVersion()
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
if (!hit.ok) {
|
|
218
|
-
failures.push(hit.error)
|
|
262
|
+
let hit
|
|
263
|
+
let failures = []
|
|
264
|
+
const npm = await raceLatestFromNpm({ fetchImpl, timeoutMs })
|
|
265
|
+
if (npm.ok) {
|
|
266
|
+
hit = npm.hit
|
|
267
|
+
} else {
|
|
268
|
+
failures = npm.errors
|
|
219
269
|
const rel = await fetchLatestRelease({ fetchImpl, timeoutMs })
|
|
220
270
|
hit = rel.ok && rel.tag
|
|
221
271
|
? { ok: true, latest: rel.tag, releaseUrl: rel.url, source: 'github' }
|
|
222
272
|
: { ok: false, error: rel.ok ? 'GitHub release 尚未发布(404)' : rel.error }
|
|
223
273
|
}
|
|
224
|
-
|
|
274
|
+
// hit 必为对象(竞速胜者或 GitHub 兜底结果,失败也已收敛为结果对象),
|
|
275
|
+
// 无需再判真值。
|
|
276
|
+
if (hit.ok) {
|
|
225
277
|
const info = {
|
|
226
278
|
current,
|
|
227
279
|
latest: hit.latest ?? null,
|
|
@@ -252,3 +304,37 @@ export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now } = {})
|
|
|
252
304
|
source: null,
|
|
253
305
|
}
|
|
254
306
|
}
|
|
307
|
+
|
|
308
|
+
// in-flight 去重:并发的 getVersionInfo 调用(面板多处同时拉取)复用同一
|
|
309
|
+
// 次检测 promise,只发一轮外网请求;promise 落定即清空,下次按缓存 TTL 走。
|
|
310
|
+
// 注意并发窗口内各调用共享第一次调用注入的 now 语义(checkedAt 取先到者)。
|
|
311
|
+
let inflight = null
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 组合结果(面板路由专用):带 1 小时内存缓存,过期才重新请求;请求失败时
|
|
315
|
+
* 若上次成功结果在 lastGood 窗口(7 天,见 LASTGOOD_TTL_MS)内则原样复用
|
|
316
|
+
* (stale-if-error),否则返回带 error 的降级结果(latest 置 null、
|
|
317
|
+
* updateAvailable 恒 false——调用方静默)。当前版本不可知(FALLBACK_VERSION
|
|
318
|
+
* 兜底)时 updateAvailable 恒 false:0.0.0 对任何 latest 都「更小」,比较必
|
|
319
|
+
* 误报新版,升级提示整体抑制。检测源:npm registry 与 npmmirror 镜像并行
|
|
320
|
+
* 竞速(先成功者胜),两路皆败才串行请求 GitHub release 兜底;三路皆败才
|
|
321
|
+
* 算本次检测失败。并发调用经 in-flight 去重复用同一次检测。成功结果的
|
|
322
|
+
* source 字段记录命中源。所有返回值均为缓存对象的浅拷贝——缓存要供最长
|
|
323
|
+
* 7 天的 lastGood 复用,调用方原地改写返回对象不得污染缓存。
|
|
324
|
+
* @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean,
|
|
325
|
+
* releaseUrl: string|null, checkedAt: string, error: string|null,
|
|
326
|
+
* source: 'npm'|'mirror'|'github'|null}>}
|
|
327
|
+
*/
|
|
328
|
+
export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now } = {}) {
|
|
329
|
+
const nowMs = typeof now === 'function' ? now() : typeof now === 'number' ? now : Date.now()
|
|
330
|
+
// TTL 内直接复用缓存;一律返回浅拷贝,隔离调用方的原地改写。
|
|
331
|
+
if (cache.info && nowMs - cache.at < CACHE_TTL_MS) return { ...cache.info }
|
|
332
|
+
// in-flight 去重:并发调用共享同一次检测;浅拷贝统一在返回前做。
|
|
333
|
+
if (!inflight) {
|
|
334
|
+
inflight = detectLatest({ fetchImpl, timeoutMs, nowMs }).finally(() => {
|
|
335
|
+
inflight = null
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
const info = await inflight
|
|
339
|
+
return { ...info }
|
|
340
|
+
}
|
package/lib/yaml-loader.mjs
CHANGED
|
@@ -15,10 +15,18 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
/** 插件包根(本文件位于 <包根>/lib/ 下,向上一级即包根)。 */
|
|
16
16
|
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* DSH_HOME 环境变量优先(支持 ~ 前缀:环境变量不经过 shell 展开,`~/xxx`
|
|
20
|
+
* 或裸 `~` 在这里手动展开为真实家目录,避免拼出相对当前目录的「字面 ~」
|
|
21
|
+
* 不可用路径),缺省 ~/.dsh。
|
|
22
|
+
*/
|
|
19
23
|
export function resolveDshHome(env = process.env) {
|
|
20
24
|
const fromEnv = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
|
|
21
|
-
|
|
25
|
+
if (fromEnv === '') return path.join(os.homedir(), '.dsh')
|
|
26
|
+
if (fromEnv === '~' || fromEnv.startsWith('~/')) {
|
|
27
|
+
return path.join(os.homedir(), fromEnv.slice(1))
|
|
28
|
+
}
|
|
29
|
+
return fromEnv
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-maoying",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,12 +14,16 @@
|
|
|
14
14
|
"bin": {
|
|
15
15
|
"maoying": "bin/maoying.mjs"
|
|
16
16
|
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.mjs",
|
|
19
|
+
"smoke": "node scripts/smoke-client.mjs"
|
|
20
|
+
},
|
|
17
21
|
"engines": {
|
|
18
22
|
"node": ">=18",
|
|
19
23
|
"dsh": ">=0.1.7 <0.3"
|
|
20
24
|
},
|
|
21
25
|
"peerDependencies": {
|
|
22
|
-
"@deepseek-ai/dsh": ">=0.1.7"
|
|
26
|
+
"@deepseek-ai/dsh": ">=0.1.7 <0.3"
|
|
23
27
|
},
|
|
24
28
|
"dependencies": {},
|
|
25
29
|
"files": [
|
|
@@ -32,6 +36,7 @@
|
|
|
32
36
|
"docs/",
|
|
33
37
|
"icon.svg",
|
|
34
38
|
"README.md",
|
|
39
|
+
"README.en.md",
|
|
35
40
|
"LICENSE",
|
|
36
41
|
"cordis.patch.yml"
|
|
37
42
|
],
|
|
@@ -353,7 +353,8 @@
|
|
|
353
353
|
tailChars: 1024
|
|
354
354
|
# ── 角色 preset:审校(one-shot,只读)────────────────────────────────────
|
|
355
355
|
# persona 逐字复制 v1 delegation-review;tool-fs 收成 read/readImage 两开、
|
|
356
|
-
# write/edit 关闭——v1 只读 toolFilter 的实体化;无 write/edit/bash
|
|
356
|
+
# write/edit 关闭——v1 只读 toolFilter 的实体化;无 write/edit/bash;带
|
|
357
|
+
# skill 仅为加载审校清单等只读方法论技能,只读不改不变。
|
|
357
358
|
- id: preset-maoying-review
|
|
358
359
|
name: "@deepseek-ai/dsh-agent-preset"
|
|
359
360
|
config:
|
|
@@ -389,6 +390,10 @@
|
|
|
389
390
|
name: "@deepseek-ai/dsh-tool-fs-search"
|
|
390
391
|
config:
|
|
391
392
|
sampleOverCapGlobResults: false
|
|
393
|
+
- id: skill-filesystem
|
|
394
|
+
name: "@deepseek-ai/dsh-skill-filesystem"
|
|
395
|
+
- id: tool-skill
|
|
396
|
+
name: "@deepseek-ai/dsh-tool-skill"
|
|
392
397
|
- id: tool-ask-user
|
|
393
398
|
name: "@deepseek-ai/dsh-tool-ask-user"
|
|
394
399
|
- id: tool-todo
|
|
@@ -125,8 +125,8 @@
|
|
|
125
125
|
name: '@deepseek-ai/dsh-tool-jobs'
|
|
126
126
|
|
|
127
127
|
# ── skills ──────────────────────────────────────────────────────────────────
|
|
128
|
-
# 主编经 restrict 的 registry deny
|
|
129
|
-
# skill
|
|
128
|
+
# 主编经 restrict 的 registry deny 看不到技能目录;写手/大纲师/审校/设定管理员
|
|
129
|
+
# 保留 skill(二期方法论技能包落点;审校只读不改,skill 仅用于加载审校清单)。
|
|
130
130
|
|
|
131
131
|
- id: skill-filesystem
|
|
132
132
|
name: '@deepseek-ai/dsh-skill-filesystem'
|
|
@@ -276,6 +276,9 @@
|
|
|
276
276
|
- read_image
|
|
277
277
|
- glob
|
|
278
278
|
- grep
|
|
279
|
+
# skill:只用于按 persona 加载 maoying-review-checklist 等只读方法论
|
|
280
|
+
# 技能;审校仍无 write/edit/bash,只读不改的约束不变。
|
|
281
|
+
- skill
|
|
279
282
|
- todo_write
|
|
280
283
|
- ask_user_question
|
|
281
284
|
|
|
@@ -13,13 +13,12 @@
|
|
|
13
13
|
* - 内部检索(热路径,不绕委派):glob / grep / read / read_image / bash
|
|
14
14
|
* - 协调:todo_write / ask_user_question / goal_* / job_* / exit_plan_mode
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* 一切通用委派与工作流工具。
|
|
16
|
+
* 主编看不到(白名单外收窄——只为省请求成本,不是安全边界):web_search /
|
|
17
|
+
* web_fetch(考据委派 research)、skill 目录(registry deny 堵 ~1.3k 注入)、
|
|
18
|
+
* 以及未列出的一切通用委派与工作流工具。
|
|
20
19
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* write / edit 保留在白名单(见 MAIN_AGENT_ALLOW 尾段):主编需要亲自做小修
|
|
21
|
+
* 小补(改 phase、补 frontmatter、微调摘要),这些活不值得委派一次。
|
|
23
22
|
*
|
|
24
23
|
* Host 工具(mcp__*、插件工具)一律不硬编码——是否挂载是检测期事实,
|
|
25
24
|
* 硬编码会让未装对应插件的宿主直接出错。本文件只留 dsh 核心工具。
|
|
@@ -36,7 +36,7 @@ description: 网文章节审校清单——语言/节奏/人设/逻辑/伏笔/
|
|
|
36
36
|
### 5. 伏笔层(对照 foreshadows.yml)
|
|
37
37
|
- 本章埋设的伏笔是否已登记(未登记记 P1,报给主编转设定管理员);
|
|
38
38
|
- 本章推进/回收的伏笔与台账状态一致;回收是否超出预期但合乎前文;
|
|
39
|
-
-
|
|
39
|
+
- 活跃伏笔是否有间隔性提醒(到限未动记 P2 提醒:high 超 10 章、normal 超 15 章、low 超 30 章,口径同 maoying-foreshadow-discipline)。
|
|
40
40
|
|
|
41
41
|
### 6. 剧透层
|
|
42
42
|
- 本章是否提前泄露后期反转(对照章节大纲与已定稿章节的既有走向);核心反转的铺垫应可回读成立但当时不点破。
|
|
@@ -149,7 +149,7 @@ references: # 依据来源,一行一个(已知设定 id 或现
|
|
|
149
149
|
|
|
150
150
|
## foreshadows.yml
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
伏笔账本(状态机语义见上文「总原则」的伏笔状态枚举)。埋设与回收必须记章节号;活跃伏笔(planted/progressing)超过 15 条时应提醒清理。
|
|
153
153
|
|
|
154
154
|
```yaml
|
|
155
155
|
- id: fs-001 # 编号不复用
|
|
@@ -170,7 +170,7 @@ references: # 依据来源,一行一个(已知设定 id 或现
|
|
|
170
170
|
世界内时间线(故事内纪年,不是写作顺序),按故事内时间排序。
|
|
171
171
|
|
|
172
172
|
```yaml
|
|
173
|
-
- year:
|
|
173
|
+
- year: 玄历1024年冬 # 故事内纪年,须含数字(check 按数字解析)
|
|
174
174
|
title: 北境雪灾
|
|
175
175
|
description: 补充说明(可空)
|
|
176
176
|
importance: 4 # 1-5,5 = 改写主线走向的大事
|
|
@@ -204,4 +204,4 @@ references: # 依据来源,一行一个(已知设定 id 或现
|
|
|
204
204
|
|
|
205
205
|
## exports/
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
导出产物:`maoying export` 的默认落盘点(--format md|txt|epub,--out 可改到别处),init 只建目录。
|