dsh-maoying 0.1.10 → 0.1.12
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 +15 -2
- package/bin/maoying.mjs +95 -50
- package/docs/creator-panel.md +31 -7
- package/docs/design.md +10 -7
- package/docs/spec.md +7 -4
- package/index.mjs +250 -88
- package/lib/base.css +174 -25
- package/lib/client.js +404 -120
- package/lib/epub.mjs +37 -6
- package/lib/novel-parse.mjs +171 -46
- package/lib/panel-core.mjs +116 -10
- package/lib/version.mjs +340 -0
- package/lib/yaml-loader.mjs +10 -2
- package/package.json +6 -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
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本提示 —— 检测线上是否有比本地更新的版本(创作台面板用)。
|
|
3
|
+
*
|
|
4
|
+
* 同构移植自 dsh-paoding tools/lib/version.mjs(用户指定模仿其机制)。检测
|
|
5
|
+
* 源三级:npm registry 与 npmmirror 镜像两路并行竞速(manifest 形状与数据
|
|
6
|
+
* 一致,先成功者胜,官方源超时 / 被墙时国内几秒内可达的镜像顶上,分钟级
|
|
7
|
+
* 同步延迟对「有没有新版」这个判断无伤大雅);两路 npm 源皆败再串行走
|
|
8
|
+
* GitHub releases/latest 兜底(独立数据源)。
|
|
9
|
+
*
|
|
10
|
+
* 零依赖纯 Node(>=18):不引 cordis / npm 依赖,fetch 可注入(缺省用
|
|
11
|
+
* globalThis.fetch),超时用 AbortSignal.timeout()。所有失败路径都不抛错,
|
|
12
|
+
* 一律收敛为 { ok: false, error } 或带 error 字段的结果对象——调用方(面板
|
|
13
|
+
* 路由)按「失败即静默」处理,绝不影响主流程。
|
|
14
|
+
*
|
|
15
|
+
* 相对 paoding 版的差异:本文件在包内 lib/ 下,package.json 向上一级即是
|
|
16
|
+
* 包根(paoding 在 tools/lib/ 需向上两级);包名与仓库名换成 dsh-maoying。
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from 'node:fs/promises'
|
|
19
|
+
|
|
20
|
+
// 仓库(与包名一致)的 GitHub latest release 接口与落地页。
|
|
21
|
+
const RELEASE_API = 'https://api.github.com/repos/lifangjin/dsh-maoying/releases/latest'
|
|
22
|
+
const RELEASE_PAGE = 'https://github.com/lifangjin/dsh-maoying/releases/latest'
|
|
23
|
+
// npm registry 的 latest 元数据端点(等价 dist-tags.latest,无认证无限流)。
|
|
24
|
+
const REGISTRY_LATEST = 'https://registry.npmjs.org/dsh-maoying/latest'
|
|
25
|
+
// npmmirror 镜像的同名端点:manifest 形状与官方源一致(顶层 version 即
|
|
26
|
+
// latest),同步自 npm registry,官方源不可达时的国内快路。
|
|
27
|
+
const MIRROR_LATEST = 'https://registry.npmmirror.com/dsh-maoying/latest'
|
|
28
|
+
|
|
29
|
+
// 读不到 package.json 时的版本兜底。当前版本不可知(source: 'fallback')时
|
|
30
|
+
// updateAvailable 恒 false —— 拿「0.0.0 恒小于任何真实版本」去比较,反而必然
|
|
31
|
+
// 对任何 latest 误报新版,升级提示必须整体抑制(读不到版本就不提示)。
|
|
32
|
+
const FALLBACK_VERSION = '0.0.0'
|
|
33
|
+
|
|
34
|
+
// 内存缓存 TTL:1 小时内复用上次成功结果,过期才真的发外网请求。
|
|
35
|
+
const CACHE_TTL_MS = 60 * 60 * 1000
|
|
36
|
+
// 请求失败时的 stale-if-error 窗口:上次成功结果在 7 天内仍原样复用(lastGood)。
|
|
37
|
+
// 窗口刻意明显大于新鲜 TTL:外网偶发抖动不该立刻撤掉本可用的升级提示。
|
|
38
|
+
const LASTGOOD_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
39
|
+
|
|
40
|
+
// 模块级缓存:只在成功后写入;失败结果不进缓存(下次调用仍会重试)。
|
|
41
|
+
let cache = { at: 0, info: null }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 本地版本:读包根 package.json 的 version。读失败(文件缺失 / 解析失败 /
|
|
45
|
+
* 字段异常)回退 FALLBACK_VERSION 并标记 source: 'fallback'。
|
|
46
|
+
* @returns {Promise<{version: string, source: 'package.json'|'fallback'}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function currentVersion() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = await readFile(new URL('../package.json', import.meta.url), 'utf8')
|
|
51
|
+
const pkg = JSON.parse(raw)
|
|
52
|
+
if (typeof pkg?.version === 'string' && pkg.version !== '') {
|
|
53
|
+
return { version: pkg.version, source: 'package.json' }
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
/* 静默回退:版本读取失败不该打扰任何主流程 */
|
|
57
|
+
}
|
|
58
|
+
return { version: FALLBACK_VERSION, source: 'fallback' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 拆版本号:剥前缀 v/V,按 '.' 切段,`-` 后为预发布后缀(原样保留)。核心段
|
|
63
|
+
* 必须纯数字才转数值,非纯数字段(如 '12abc'、空串)一律按 0——parseInt 的
|
|
64
|
+
* 前缀解析会把 '12abc' 当成 12,这里显式拒绝。
|
|
65
|
+
*/
|
|
66
|
+
function parseVersion(value) {
|
|
67
|
+
let s = String(value ?? '').trim()
|
|
68
|
+
if (s.startsWith('v') || s.startsWith('V')) s = s.slice(1)
|
|
69
|
+
const dash = s.indexOf('-')
|
|
70
|
+
const coreText = dash === -1 ? s : s.slice(0, dash)
|
|
71
|
+
const pre = dash === -1 ? '' : s.slice(dash + 1)
|
|
72
|
+
const core = coreText.split('.').map((n) => (/^\d+$/.test(n) ? Number(n) : 0))
|
|
73
|
+
return { core, pre }
|
|
74
|
+
}
|
|
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
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 版本比较(可测纯函数):a < b 返回 -1,相等返回 0,a > b 返回 1。
|
|
99
|
+
* 简化语义(不追求完整 semver):剥前缀 v/V 后按 '.' 逐段数值比较,缺段补 0;
|
|
100
|
+
* 核心段相同时带预发布后缀(如 0.3.0-rc.1)视为低于无后缀同版本,两个都有
|
|
101
|
+
* 预发布后缀则按 '.' 切段逐段比较(数字段数值比、其余字典序,见 comparePre)。
|
|
102
|
+
*/
|
|
103
|
+
export function compareVersions(a, b) {
|
|
104
|
+
const pa = parseVersion(a)
|
|
105
|
+
const pb = parseVersion(b)
|
|
106
|
+
const len = Math.max(pa.core.length, pb.core.length)
|
|
107
|
+
for (let i = 0; i < len; i++) {
|
|
108
|
+
const x = pa.core[i] ?? 0
|
|
109
|
+
const y = pb.core[i] ?? 0
|
|
110
|
+
if (x !== y) return x < y ? -1 : 1
|
|
111
|
+
}
|
|
112
|
+
if (pa.pre !== pb.pre) {
|
|
113
|
+
if (pa.pre === '') return 1 // 无后缀 > 有后缀
|
|
114
|
+
if (pb.pre === '') return -1
|
|
115
|
+
return comparePre(pa.pre, pb.pre)
|
|
116
|
+
}
|
|
117
|
+
return 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 请求 GitHub latest release。任何失败(网络 / 超时 / 限流 / 解析)都不抛出,
|
|
122
|
+
* 返回 { ok: false, error };仓库尚无 release(404)不算错误,返回
|
|
123
|
+
* { ok: true, tag: null, url: null, notFound: true }。成功返回
|
|
124
|
+
* { ok: true, tag: 'vX.Y.Z', url: releasePage }。
|
|
125
|
+
*/
|
|
126
|
+
export async function fetchLatestRelease({ fetchImpl, timeoutMs = 3000 } = {}) {
|
|
127
|
+
const doFetch = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch
|
|
128
|
+
if (typeof doFetch !== 'function') return { ok: false, error: 'fetch 不可用(当前运行时无 fetch 实现)' }
|
|
129
|
+
// GitHub API 必须带 User-Agent,顺手报上包名与本地版本便于排障。
|
|
130
|
+
const { version } = await currentVersion()
|
|
131
|
+
try {
|
|
132
|
+
const res = await doFetch(RELEASE_API, {
|
|
133
|
+
headers: {
|
|
134
|
+
'user-agent': `dsh-maoying/${version}`,
|
|
135
|
+
accept: 'application/vnd.github+json',
|
|
136
|
+
},
|
|
137
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
138
|
+
})
|
|
139
|
+
if (res.status === 404) return { ok: true, tag: null, url: null, notFound: true }
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
// 非 404 失败(403 限流、5xx 等):带上 GitHub 的 message 便于排障
|
|
142
|
+
//(典型如未认证配额用尽的 rate limit 提示),仍按失败静默收敛。
|
|
143
|
+
let detail = ''
|
|
144
|
+
try {
|
|
145
|
+
const body = await res.json()
|
|
146
|
+
if (body && typeof body.message === 'string' && body.message !== '') detail = `: ${body.message}`
|
|
147
|
+
} catch {
|
|
148
|
+
/* 正文非 JSON 时忽略,只报状态码 */
|
|
149
|
+
}
|
|
150
|
+
return { ok: false, error: `GitHub API HTTP ${res.status}${detail}` }
|
|
151
|
+
}
|
|
152
|
+
let data = null
|
|
153
|
+
try {
|
|
154
|
+
data = await res.json()
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return { ok: false, error: `GitHub API 响应解析失败: ${err?.message ?? err}` }
|
|
157
|
+
}
|
|
158
|
+
const tag = typeof data?.tag_name === 'string' && data.tag_name !== '' ? data.tag_name : null
|
|
159
|
+
if (!tag) return { ok: false, error: 'GitHub API 响应缺少 tag_name' }
|
|
160
|
+
const url = typeof data?.html_url === 'string' && data.html_url !== '' ? data.html_url : RELEASE_PAGE
|
|
161
|
+
return { ok: true, tag, url }
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 请求一个 npm 系 manifest 端点(官方源与 npmmirror 镜像共用同一形状:完整
|
|
169
|
+
* manifest,顶层 version 即 latest)。任何失败(网络 / 超时 / 解析 / 形状不对)
|
|
170
|
+
* 都不抛出,返回 { ok: false, error };成功返回 { ok: true, latest: 'vX.Y.Z',
|
|
171
|
+
* releaseUrl, source }(端点的 version 不带 v 前缀,这里统一补齐,与 GitHub
|
|
172
|
+
* tag 的显示口径一致)。
|
|
173
|
+
*/
|
|
174
|
+
async function fetchLatestManifest(url, label, source, { fetchImpl, timeoutMs = 3000 } = {}) {
|
|
175
|
+
const doFetch = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch
|
|
176
|
+
if (typeof doFetch !== 'function') return { ok: false, error: 'fetch 不可用(当前运行时无 fetch 实现)' }
|
|
177
|
+
try {
|
|
178
|
+
const res = await doFetch(url, {
|
|
179
|
+
headers: { accept: 'application/vnd.npm+json' },
|
|
180
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
181
|
+
})
|
|
182
|
+
if (!res.ok) return { ok: false, error: `${label} HTTP ${res.status}` }
|
|
183
|
+
let data = null
|
|
184
|
+
try {
|
|
185
|
+
data = await res.json()
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { ok: false, error: `${label} 响应解析失败: ${err?.message ?? err}` }
|
|
188
|
+
}
|
|
189
|
+
// /<name>/latest 返回该版本的完整 manifest,顶层 version 即 latest 版本号;
|
|
190
|
+
// CDN 缓存抖动可能回形状不对的正文,按失败处理走下一级检测源。
|
|
191
|
+
const ver = typeof data?.version === 'string' && data.version !== '' ? data.version : null
|
|
192
|
+
if (!ver) return { ok: false, error: `${label} 响应缺少 version` }
|
|
193
|
+
return { ok: true, latest: `v${ver}`, releaseUrl: RELEASE_PAGE, source }
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 请求 npm registry(官方源)的 latest 元数据——版本检测首选源。
|
|
201
|
+
*/
|
|
202
|
+
export async function fetchLatestFromRegistry(opts) {
|
|
203
|
+
return await fetchLatestManifest(REGISTRY_LATEST, 'npm registry', 'npm', opts)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 请求 npmmirror 镜像的 latest 元数据——官方源不可达(超时 / 被墙 / 代理
|
|
208
|
+
* 黑洞)时的国内快路:manifest 形状与官方源一致,同步延迟分钟级,对「有
|
|
209
|
+
* 没有新版」这个判断无伤大雅。
|
|
210
|
+
*/
|
|
211
|
+
export async function fetchLatestFromMirror(opts) {
|
|
212
|
+
return await fetchLatestManifest(MIRROR_LATEST, 'npmmirror', 'mirror', opts)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
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
|
+
* 未处理异常。
|
|
224
|
+
*/
|
|
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 }) {
|
|
261
|
+
const { version: current, source: currentSource } = await currentVersion()
|
|
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
|
|
269
|
+
const rel = await fetchLatestRelease({ fetchImpl, timeoutMs })
|
|
270
|
+
hit = rel.ok && rel.tag
|
|
271
|
+
? { ok: true, latest: rel.tag, releaseUrl: rel.url, source: 'github' }
|
|
272
|
+
: { ok: false, error: rel.ok ? 'GitHub release 尚未发布(404)' : rel.error }
|
|
273
|
+
}
|
|
274
|
+
// hit 必为对象(竞速胜者或 GitHub 兜底结果,失败也已收敛为结果对象),
|
|
275
|
+
// 无需再判真值。
|
|
276
|
+
if (hit.ok) {
|
|
277
|
+
const info = {
|
|
278
|
+
current,
|
|
279
|
+
latest: hit.latest ?? null,
|
|
280
|
+
// 当前版本不可知(fallback 0.0.0)时不提示升级,latest 缺失同理。
|
|
281
|
+
updateAvailable:
|
|
282
|
+
currentSource === 'fallback' || !hit.latest ? false : compareVersions(current, hit.latest) < 0,
|
|
283
|
+
releaseUrl: hit.releaseUrl ?? null,
|
|
284
|
+
checkedAt: new Date(nowMs).toISOString(),
|
|
285
|
+
error: null,
|
|
286
|
+
source: hit.source ?? null,
|
|
287
|
+
}
|
|
288
|
+
cache = { at: nowMs, info }
|
|
289
|
+
return info
|
|
290
|
+
}
|
|
291
|
+
// 三路皆败:优先复用 lastGood 窗口(7 天)内的上次成功结果;超窗才给降级
|
|
292
|
+
// 结果(不写缓存)。error 把各路原因都带上,并附代理排查提示——最常见的
|
|
293
|
+
// 「浏览器能上网、DSH 进程检测失败」是启动环境的代理变量指向了不可用的
|
|
294
|
+
// 代理(DSH 遵循 HTTPS_PROXY 等变量,浏览器不走它们)。
|
|
295
|
+
const combined = `三路检测均失败(npm registry: ${failures[0]};npmmirror: ${failures[1]};GitHub: ${hit.error})。若本机经代理访问外网,请确认代理可用后重启 DSH 再试——DSH 进程遵循 HTTPS_PROXY / HTTP_PROXY 环境变量,浏览器不走它们,两者连通性可能不同`
|
|
296
|
+
if (cache.info && nowMs - cache.at < LASTGOOD_TTL_MS) return cache.info
|
|
297
|
+
return {
|
|
298
|
+
current,
|
|
299
|
+
latest: null,
|
|
300
|
+
updateAvailable: false,
|
|
301
|
+
releaseUrl: null,
|
|
302
|
+
checkedAt: new Date(nowMs).toISOString(),
|
|
303
|
+
error: combined,
|
|
304
|
+
source: null,
|
|
305
|
+
}
|
|
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.12",
|
|
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": [
|
|
@@ -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
|
- 本章是否提前泄露后期反转(对照章节大纲与已定稿章节的既有走向);核心反转的铺垫应可回读成立但当时不点破。
|