dsh-maoying 0.1.13 → 0.1.14
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 +2 -2
- package/README.md +2 -2
- package/docs/creator-panel.md +11 -6
- package/index.mjs +35 -2
- package/lib/base.css +25 -0
- package/lib/client.js +162 -18
- package/lib/panel-core.mjs +56 -5
- package/lib/self-update.mjs +263 -0
- package/lib/version.mjs +11 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -74,8 +74,8 @@ A reply of "ok / sure / go on" counts as confirmation; the chief advances direct
|
|
|
74
74
|
Open "**毛颖创作台**" at the bottom of the DSH sidebar — a full-page cockpit for the human eye, beyond what agents do:
|
|
75
75
|
|
|
76
76
|
- **Chapters**: status filters + the writing queue; click a chapter for full-page reading — back button, outline side-by-side (Markdown rendered), read-only manuscript, five narrative-dimension fields, one-click status flow.
|
|
77
|
-
- **Novel Settings**: form-based editing of project.yml — title / author / genre / planned length / language / workflow phase / co-writing mode / synopsis; saving preserves the file's existing comments.
|
|
78
|
-
- **Top bar**: current version always visible; a "new version ↗" badge links to the release page when npm has a newer one; the gear opens Settings — UI language (中文/English), six font-size steps (100%–150%), all applied immediately and remembered.
|
|
77
|
+
- **Novel Settings**: form-based editing of project.yml — title / author / genre / planned length / language / workflow phase / co-writing mode / synopsis; saving preserves the file's existing comments. If project.yml was changed externally after the form loaded (manual edits or an agent advancing the phase), saving is rejected with a prompt to re-edit from the latest content instead of overwriting.
|
|
78
|
+
- **Top bar**: current version always visible; a "new version ↗" badge links to the release page when npm has a newer one, next to an "Upgrade now" button that installs the latest release into the current DSH profile with pnpm (profile-directory installs only; a DeepSeek restart is required to take effect); the gear opens Settings — UI language (中文/English), six font-size steps (100%–150%), all applied immediately and remembered.
|
|
79
79
|
|
|
80
80
|
The panel only writes state (chapter status, project.yml, index.yml rebuilds), never prose — prose belongs to the author and the chief.
|
|
81
81
|
|
package/README.md
CHANGED
|
@@ -74,8 +74,8 @@ npm install -g dsh-maoying # 或装成全局 maoying 命令
|
|
|
74
74
|
DSH 左侧栏底部点「**毛颖创作台**」打开全页面板(改文件、跑流程之外,给肉眼看的驾驶舱):
|
|
75
75
|
|
|
76
76
|
- **章节**:状态筛选 + 按单写作清单;点章节进整页阅读——返回按钮、章纲对照(Markdown 渲染)、只读正文、五维叙事字段、一键状态流转。
|
|
77
|
-
- **小说设置**:project.yml
|
|
78
|
-
- **顶栏**:当前版本号常驻;npm 上有新版时亮「有新版
|
|
77
|
+
- **小说设置**:project.yml 的书名/作者/题材/篇幅/语言/工作流阶段/共创模式/简介表单化编辑,保存保留文件里原有注释;表单载入后 project.yml 被外部(作者手改 / agent 推进阶段)改过时拒绝保存并提示重改,不覆盖人工改动。
|
|
78
|
+
- **顶栏**:当前版本号常驻;npm 上有新版时亮「有新版 ↗」徽章直连发布页,旁边「一键升级」按钮直接用 pnpm 把最新包装进当前 DSH 档(仅支持 profile 目录安装;装完需重启 DeepSeek 才生效);齿轮打开系统设置,界面语言中文/English 可切、字号六档(100%–150%)整体缩放,均立即生效并记忆。
|
|
79
79
|
|
|
80
80
|
面板只写状态(章节状态、project.yml、index.yml 重建),不改正文——正文永远归作者与主编。
|
|
81
81
|
|
package/docs/creator-panel.md
CHANGED
|
@@ -39,17 +39,19 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
|
|
|
39
39
|
|---|---|---|---|
|
|
40
40
|
| `client.css` | GET | — | text/css(lib/base.css 现读,no-cache) |
|
|
41
41
|
| `update-check` | GET | — | `{ ok, current, latest, updateAvailable, releaseUrl, checkedAt, error, source }`——新版检测(npm registry → npmmirror → GitHub 三级源,1h 缓存 + 7 天 stale-if-error,全败静默降级为 latest=null / error 非空) |
|
|
42
|
+
| `self-update` | POST | — | `{ ok, from, to, restartRequired: true }` 或 `{ ok: false, kind, error }`——面板内一键升级:安装形态/pnpm 探测通过后跑 `pnpm --dir <profileDir> add dsh-maoying@latest`(180s 超时,无 shell)。仅支持 `<dshHome>/profiles/<profile>/node_modules/dsh-maoying` 真实目录安装;symlink/dev 检出(dev-link)与非 profile 布局(unsupported-layout)、pnpm 缺失(pnpm-missing)、超时(timeout)、pnpm 非零退出(pnpm-failed)一律 200 + ok:false。**装完只换磁盘包,运行中进程仍是旧代码,restartRequired 恒 true——重启 DeepSeek 后生效** |
|
|
42
43
|
| `host-debug` | GET | — | `{ ok, hostVersion, trace, shape, registry, runtime }`——现场诊断(见下节) |
|
|
43
44
|
| `workspaces` | GET | — | `{ ok, workspaces: [{ path, name, hasNovel }] }`——workspaceRegistry.list() 逐根探测 `<root>/novel/project.yml` |
|
|
44
45
|
| `state` | GET | `path=<root>` | 全量快照(见 §3 契约) |
|
|
46
|
+
| `check` | GET | `path=<root>` | `{ ok, problems: [{ level, file, message }] }`——静态体检:与 CLI check 同源的 checkProject,读章节 frontmatter 原文(快照会把越枚举 status 归一成 pending,这里透出原始问题清单;level ∈ error/warn,可为空数组),概览「静态体检」黄条取数 |
|
|
45
47
|
| `chapter` | GET | `path`, `file`(chapters/ 下文件名) | `{ ok, file, frontmatter, body, outline }`——正文全文 + 对应章纲(`{ file, words, body }`,outlines/ 无对应文件时 null),章节抽屉用 |
|
|
46
48
|
| `chapter-status` | POST | `{ path, file, status }` | `{ ok }`——frontmatter status 行原地替换;status 必须 ∈ pending/writing/review/accepted |
|
|
47
49
|
| `foreshadow-status` | POST | `{ path, id, status }` | `{ ok }`——foreshadows.yml 对应条目 status 行替换;∈ planted/progressing/resolved/abandoned |
|
|
48
50
|
| `phase` | POST | `{ path, phase }` | `{ ok }`——project.yml phase 行替换;七态枚举 |
|
|
49
|
-
| `project-save` | POST | `{ path, values }`(8
|
|
51
|
+
| `project-save` | POST | `{ path, values, base_hash? }`(values 为 8 平键 title/author/genre/length/language/phase/mode/description;base_hash 为表单载入时的 project.yml 指纹,即快照 `project_hash`) | `{ ok, project_hash }`——mutateProject 逐键改写保行尾注释、缺键按模板补齐;phase/mode 越枚举与空书名 422;base_hash 与写入前现读原文不一致 → 409「外部修改冲突」拒写不落盘(人工改动保护:表单载入后文件被作者/agent 改过即拒绝静默覆盖;缺省 base_hash 跳过检查,兼容旧调用方)。project_hash 为写后新指纹,客户端可作下一轮基线 |
|
|
50
52
|
| `rebuild-index` | POST | `{ path }` | `{ ok, chapters }`——照 CLI index 同一逻辑重建 index.yml |
|
|
51
53
|
|
|
52
|
-
共
|
|
54
|
+
共 13 条。写操作全部「行级原地替换」:只在 frontmatter/目标 yml 的对应行上做字符串手术,写前读、写后校验(替换计数 ≠1 即 409 拒写并报错),不做整文件重排——把对 git diff 的扰动压到最小,也与 agent 的写法兼容。(`self-update` 是例外:它不碰小说文件,跑的是 pnpm 安装。)
|
|
53
55
|
|
|
54
56
|
### host-debug 端点(现场诊断)
|
|
55
57
|
|
|
@@ -71,13 +73,14 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
|
|
|
71
73
|
"root": "<workspace root>",
|
|
72
74
|
"novelDir": "<root>/novel",
|
|
73
75
|
"found": true, // false 时其余字段缺省,面板显示「未初始化」引导
|
|
76
|
+
"project_hash": "9f86d0…", // sha256(project.yml 原文) hex——面板保存回传 project-save 的 base_hash 做旧版检测(人工改动保护),读不到为 null;found:false 时恒为 null
|
|
74
77
|
"project": { "title","author","genre","length","language","phase","mode","description" }, // mode: ai-draft|co-write|author-draft(五期)
|
|
75
78
|
"chapters": [{ "file","volume","num","title","status","words","summary",
|
|
76
79
|
"cognitive_frame","emotional_anchor","world_texture",
|
|
77
80
|
"suspense","interpersonal_tension" }], // 按 volume,num 升序;words 为 CJK 计数
|
|
78
81
|
"characters": [{ "file","name","age","gender","role","appearance","personality",
|
|
79
82
|
"background","motivation","arc","markers" }], // markers 数组,缺省 []
|
|
80
|
-
"relations": [{ "a","b","type","description","intensity","started","ended" }],
|
|
83
|
+
"relations": [{ "a","b","type","description","intensity","started","ended" }], // a/b:relations.yml 里是角色 slug,出快照时对得上角色卡的端点归一成 name(关系图按 name 找节点),对不上原样保留
|
|
81
84
|
"foreshadows": [{ "id","title","status","priority","planted_chapter",
|
|
82
85
|
"expected_resolve","resolved_chapter","notes","source" }], // source: plan|backfill,缺省 plan
|
|
83
86
|
"timeline": [{ "year","title","description","importance","chapter" }],
|
|
@@ -105,7 +108,7 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
|
|
|
105
108
|
|
|
106
109
|
九个 tab(顺序即默认排布,第 9 个为后续迭代新增):
|
|
107
110
|
|
|
108
|
-
1. **概览**:project 卡(书名大字 + 类型/篇幅/语言 pills + 阶段徽章 + 简介);统计卡(总字数/章节数/状态分布条);最近更新(chapters 按 mtime 前 5);warnings
|
|
111
|
+
1. **概览**:project 卡(书名大字 + 类型/篇幅/语言 pills + 阶段徽章 + 简介);统计卡(总字数/章节数/状态分布条);最近更新(chapters 按 mtime 前 5);warnings 黄条;静态体检黄条(check 路由问题清单,见 §4.1「人工改动保护 + 静态体检黄条」(0.1.14)条目)。
|
|
109
112
|
2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章,共创模式的主工作清单);行点击开整页阅读视图(0.1.12 起替代原右侧抽屉——fixed 侧弹层会被 DSH 侧边栏遮挡):返回按钮 + GET chapter 正文(只读,等宽滚动)+ 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted)。
|
|
110
113
|
3. **角色**:卡墙(姓名/role 徽章四色/年龄性别/性格一句/动机一行),点击看全卡(含 arc/markers/background)。
|
|
111
114
|
4. **伏笔**:四列看板(埋设/推进/已回收/弃用),卡片含 priority 色点(high 红/normal 灰/low 淡)、`source: backfill` 的「回填」小徽章(账本回填来的条目,区别于写前规划)、埋设章→预期回收章、notes 全文直显(0.1.10 去掉两行截断);卡片按钮:推进到下一状态(planted→progressing→resolved;abandoned 只能从看板拖不动、按钮给「弃用」)。列头计数。
|
|
@@ -116,16 +119,18 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
|
|
|
116
119
|
|
|
117
120
|
状态徽章色(全局统一):pending 灰 / writing 蓝 / review 橙 / accepted 绿;phase 七态沿用七色 pill。文案全中文、禁 emoji 图标(用 ui-primitives 图标或纯 CSS)。
|
|
118
121
|
|
|
119
|
-
## 4.1 后续迭代记录(0.1.10 – 0.1.
|
|
122
|
+
## 4.1 后续迭代记录(0.1.10 – 0.1.14)
|
|
120
123
|
|
|
121
124
|
- **MaskPortal**:抽屉/弹层统一 portal 到 `document.body`——面板容器挂在宿主会话中栏,中栏 transform/overflow 会把 `position: fixed` 劫持成相对自身并连同裁剪(桌面端「点了没反应」实测病灶);无 createPortal 的老宿主退回原地渲染。
|
|
122
125
|
- **顶栏版本区**:当前版本号常驻;`update-check` 路由(模仿 dsh-paoding:npm registry → npmmirror → GitHub 三级源,1h 缓存 + 7 天 stale-if-error,全败静默)发现新版时亮「有新版 ↗」徽章。
|
|
123
126
|
- **系统设置**:齿轮弹窗,字号六档(100/110/120/130/140/150%)——面板根与 portal 节点读同一 `--myt-zoom` 变量整体 zoom,localStorage 记忆。
|
|
124
|
-
- **界面语言**:系统设置可选中文/English——全部 UI 文案走 `I18N` 词典(zh/en 各
|
|
127
|
+
- **界面语言**:系统设置可选中文/English——全部 UI 文案走 `I18N` 词典(zh/en 各 211 条,`tt`/`tf` 取词 + `{x}` 插值,zh 值与历史硬编码逐字一致保证中文界面零回归),切换立即生效整树重渲并 localStorage 记忆;语言自称(中文/日本語)与侧栏入口品牌名不翻译。
|
|
125
128
|
- **Markdown 渲染**:零依赖渲染器(先整体转义再变换),章纲对照与世界观/科学设定描述不再裸显 md 源码;正文手稿仍走 pre。
|
|
126
129
|
- **章节整页阅读**:替代右侧抽屉(侧边栏遮挡问题),返回按钮 + 章纲对照 + 五维 + 状态流转原位保留;阅读态隐藏 tab 条。
|
|
127
130
|
- **小说设置 tab**(第 9 个):project.yml 平键表单化编辑,`POST project-save` → `mutateProject` 逐键改写保行尾注释、缺键按模板补齐、phase/mode 越枚举与空书名 422。
|
|
128
131
|
- **字号六档改百分比**(0.1.13):档位从中文名(小/标准/大…)改为 100/110/120/130/140/150% 直读,旧 localStorage 档位值回落 100%。
|
|
132
|
+
- **人工改动保护 + 静态体检黄条**(0.1.14):快照顶层新增 `project_hash`(project.yml 原文 sha256,读不到为 null)——小说设置保存回传 `base_hash`,表单载入后 project.yml 被作者手改或 agent(如 mutatePhase 推进 phase)动过时 `project-save` 409 拒写并报「外部修改冲突」,客户端重拉快照把表单放回磁盘真值,不再静默覆盖同名键丢人工改动;概览新增「静态体检」黄条(GET `check`,与 CLI check 同源的 checkProject,透出快照归一掩盖的越枚举等问题,含 error 级时整条转错误色)。路由 11 → 12 条;I18N zh/en 各 209 → 211 条(check.title / check.fullHint)。
|
|
133
|
+
- **面板内一键升级**(0.1.14):`POST self-update`(lib/self-update.mjs,依赖全部可注入)——顶栏新版徽章旁加「一键升级」按钮,安装形态(`<dshHome>/profiles/<profile>/node_modules/dsh-maoying` 真实目录,symlink/dev 检出一票否决)与 pnpm 可执行性(PATH → homebrew/pnpm 官方安装位候选)双闸通过后跑 `pnpm --dir <profileDir> add dsh-maoying@latest`(180s 超时、无 shell、模块级 in-flight 去重),成功后失效版本检测缓存并提示「已安装 {to},重启 DeepSeek 后生效」——**装完只换磁盘包,重启才生效,UI 文案照实说**;安装中按钮禁用(前端该请求超时放宽到 200s),「已安装,待重启」后徽章收起。路由 12 → 13 条;I18N zh/en 各 211 → 219 条(header.upgrade* / toast.selfUpdate.*)。
|
|
129
134
|
|
|
130
135
|
## 5. 验收基线
|
|
131
136
|
|
package/index.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync,
|
|
|
55
55
|
import { createHash } from 'node:crypto'
|
|
56
56
|
import { pathToFileURL } from 'node:url'
|
|
57
57
|
import { getVersionInfo } from './lib/version.mjs'
|
|
58
|
+
import { selfUpdate } from './lib/self-update.mjs'
|
|
58
59
|
import { readFile } from 'node:fs/promises'
|
|
59
60
|
import { createRequire } from 'node:module'
|
|
60
61
|
import os from 'node:os'
|
|
@@ -72,6 +73,8 @@ import {
|
|
|
72
73
|
rebuildIndex,
|
|
73
74
|
resolveNovelRoot,
|
|
74
75
|
} from './lib/panel-core.mjs'
|
|
76
|
+
// 静态体检(check 路由)与 CLI check 同源:直接复用 novel-parse 的检查器。
|
|
77
|
+
import { checkProject } from './lib/novel-parse.mjs'
|
|
75
78
|
import { loadYamlLib, resolveDshHome } from './lib/yaml-loader.mjs'
|
|
76
79
|
|
|
77
80
|
// loadYamlLib / resolveDshHome 已迁至 lib/yaml-loader.mjs(创作台业务层与
|
|
@@ -937,7 +940,7 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
|
|
|
937
940
|
}
|
|
938
941
|
|
|
939
942
|
/**
|
|
940
|
-
* 注册创作台全部路由(docs/creator-panel.md §2 的
|
|
943
|
+
* 注册创作台全部路由(docs/creator-panel.md §2 的 13 条)。返回宿主 disposer;
|
|
941
944
|
* webServer 缺席(非 web 部署)时抛错,由 apply 侧统一降级为 warn。
|
|
942
945
|
*/
|
|
943
946
|
export async function registerCreatorRoutes(ctx) {
|
|
@@ -990,6 +993,23 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
990
993
|
return
|
|
991
994
|
}
|
|
992
995
|
|
|
996
|
+
if (route === 'self-update' && req.method === 'POST') {
|
|
997
|
+
// 面板内一键升级:形态/pnpm 检查 + `pnpm --dir <profileDir> add
|
|
998
|
+
// dsh-maoying@latest`(细节与安全边界见 lib/self-update.mjs)。可
|
|
999
|
+
// 预期的失败(dev-link / unsupported-layout / pnpm-missing / 超时 /
|
|
1000
|
+
// pnpm 非零退出)统一 HTTP 200 + ok:false,kind 给客户端分流、
|
|
1001
|
+
// message 经 sanitizeMessage 脱敏后透出;成功也带 restartRequired:
|
|
1002
|
+
// true——装完的只是磁盘上的新包,运行中的进程仍是旧代码,必须重启
|
|
1003
|
+
// DeepSeek 才生效,客户端文案必须照实转述。
|
|
1004
|
+
const r = await selfUpdate({ logger: ctx.logger })
|
|
1005
|
+
if (r.ok) {
|
|
1006
|
+
json(res, 200, { ok: true, from: r.from ?? null, to: r.to ?? null, restartRequired: true })
|
|
1007
|
+
} else {
|
|
1008
|
+
json(res, 200, { ok: false, kind: r.kind, error: sanitizeMessage(r.message ?? r.kind) })
|
|
1009
|
+
}
|
|
1010
|
+
return
|
|
1011
|
+
}
|
|
1012
|
+
|
|
993
1013
|
if (route === 'host-debug' && req.method === 'GET') {
|
|
994
1014
|
// 现场诊断:宿主版本五级探针轨迹 + 形状判定。与 client.css 同级信任
|
|
995
1015
|
// 围栏(loopback + 可信 Host 才放行),只暴露版本与路径类信息。
|
|
@@ -1057,6 +1077,17 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
1057
1077
|
return
|
|
1058
1078
|
}
|
|
1059
1079
|
|
|
1080
|
+
if (route === 'check' && req.method === 'GET') {
|
|
1081
|
+
// 静态体检:checkProject 读章节 frontmatter 原文——快照会把越枚举
|
|
1082
|
+
// status 归一成 pending(人工改坏在面板里看不出来),这里原样透出
|
|
1083
|
+
// 问题清单,概览「静态体检」黄条取数。checkProject 自带的 ok 是
|
|
1084
|
+
// 「无 error」语义、summary 是 CLI 退出码原料,都不进面板契约
|
|
1085
|
+
// (§2 出参就是 { ok, problems }),客户端自己按 level 计数。
|
|
1086
|
+
const novelDir = resolveNovelRoot(url.searchParams.get('path'), ctx.workspaceRegistry)
|
|
1087
|
+
json(res, 200, { ok: true, problems: checkProject(novelDir).problems })
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1060
1091
|
if (route === 'chapter' && req.method === 'GET') {
|
|
1061
1092
|
// 章节正文全文(抽屉用):file 只认 chapters/ 下纯文件名;响应组装
|
|
1062
1093
|
// 直传 readChapter 结果(五期起含对应章纲 outline 字段,无章纲为
|
|
@@ -1090,7 +1121,9 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
1090
1121
|
if (route === 'project-save' && req.method === 'POST') {
|
|
1091
1122
|
const body = await readBody(req, res)
|
|
1092
1123
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
1093
|
-
|
|
1124
|
+
// base_hash = 面板表单载入时的 project.yml 指纹:外部改过 → 409 拒写
|
|
1125
|
+
// (人工改动保护);旧客户端不带该字段 → undefined,跳过检查。
|
|
1126
|
+
json(res, 200, mutateProject(novelDir, body.values, { baseHash: body.base_hash }))
|
|
1094
1127
|
return
|
|
1095
1128
|
}
|
|
1096
1129
|
|
package/lib/base.css
CHANGED
|
@@ -294,6 +294,13 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
|
|
|
294
294
|
.myt-stripIcon { display: inline-flex; flex: none; }
|
|
295
295
|
.myt-stripText { min-width: 0; overflow-wrap: anywhere; }
|
|
296
296
|
.myt-stripWarn { border-color: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 10%, transparent); color: var(--dsw-alias-state-warn-primary);}
|
|
297
|
+
/* 静态体检黄条的错误强调(叠加在 myt-stripWarn 上):含 error 级问题时整条
|
|
298
|
+
转错误色——纯警告(0 错误)保持黄色不动,一眼分清「有错」和「仅提醒」。 */
|
|
299
|
+
.myt-stripCheckErr {
|
|
300
|
+
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 40%, transparent);
|
|
301
|
+
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
|
|
302
|
+
color: var(--dsw-alias-state-error-primary);
|
|
303
|
+
}
|
|
297
304
|
.myt-stripError {
|
|
298
305
|
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 40%, transparent);
|
|
299
306
|
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
|
|
@@ -705,6 +712,24 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
|
|
|
705
712
|
}
|
|
706
713
|
.myt-verBadge:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 22%, transparent); }
|
|
707
714
|
|
|
715
|
+
/* 一键升级按钮:与版本徽章同形(描边胶囊),装完/安装中禁用降透明度 */
|
|
716
|
+
.myt-verUpgrade {
|
|
717
|
+
margin-left: 6px;
|
|
718
|
+
padding: 2px 10px;
|
|
719
|
+
border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 45%, transparent);
|
|
720
|
+
border-radius: 999px;
|
|
721
|
+
background: transparent;
|
|
722
|
+
color: var(--dsw-alias-state-business-primary);
|
|
723
|
+
font-size: 11px;
|
|
724
|
+
line-height: 16px;
|
|
725
|
+
font-weight: 500;
|
|
726
|
+
vertical-align: middle;
|
|
727
|
+
white-space: nowrap;
|
|
728
|
+
cursor: pointer;
|
|
729
|
+
}
|
|
730
|
+
.myt-verUpgrade:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); }
|
|
731
|
+
.myt-verUpgrade:disabled { opacity: 0.6; cursor: default; }
|
|
732
|
+
|
|
708
733
|
/* ── 版本号 + 系统设置(字号五档,面板整体 zoom)────────────────────────── */
|
|
709
734
|
.myt-verTag {
|
|
710
735
|
display: inline-block;
|
package/lib/client.js
CHANGED
|
@@ -53,13 +53,19 @@ window.__ModuleLoader__.load({
|
|
|
53
53
|
var CSS_KEY = "maoying-creator/base";
|
|
54
54
|
var WS_STORAGE_KEY = "maoying.workspace";
|
|
55
55
|
var API_TIMEOUT_MS = 60000;
|
|
56
|
+
// 一键升级单独放宽:服务端 pnpm add 超时上限 180s,统一 60s 会先掐断,
|
|
57
|
+
// 故 self-update 请求走 200s(留 20s 余量),其余请求仍用 API_TIMEOUT_MS。
|
|
58
|
+
var SELF_UPDATE_TIMEOUT_MS = 200000;
|
|
56
59
|
|
|
57
60
|
var STATUS_FLOW = ["pending", "writing", "review", "accepted"];
|
|
58
61
|
var FS_ORDER = ["planted", "progressing", "resolved", "abandoned"];
|
|
59
62
|
var FS_FLOW = { planted: "progressing", progressing: "resolved" };
|
|
60
63
|
var PHASES = ["idea", "setting", "outline", "writing", "review", "consistency", "export"];
|
|
61
|
-
//
|
|
62
|
-
|
|
64
|
+
// clue.* 词典键是小驼峰,数据里的 kind 按契约(templates/novel/README.md、
|
|
65
|
+
// docs/spec.md)是 clue|red-herring|deduction|question——此前键表误按
|
|
66
|
+
// snake_case + inference 写,红鲱鱼/推理两组永远查不中、只能裸显英文原值。
|
|
67
|
+
// 尾两个旧键是历史手写数据的容错别名,契约值优先。
|
|
68
|
+
var CLUE_KIND_KEYS = { clue: "clue", "red-herring": "redHerring", deduction: "inference", question: "question", red_herring: "redHerring", inference: "inference" };
|
|
63
69
|
var TABS = [
|
|
64
70
|
["overview", "tabs.overview"], ["chapters", "tabs.chapters"], ["characters", "tabs.characters"],
|
|
65
71
|
["foreshadows", "tabs.foreshadows"], ["relations", "tabs.relations"], ["timeline", "tabs.timeline"],
|
|
@@ -156,7 +162,9 @@ window.__ModuleLoader__.load({
|
|
|
156
162
|
|
|
157
163
|
// ── 服务端 API 助手(同源 /api/maoying/*,非 2xx 抛错)─────────────────
|
|
158
164
|
// 错误统一 toast(读路由调用方另留卡内错误态);总超时防按钮永久转圈。
|
|
159
|
-
function maoyingApi(path, body) {
|
|
165
|
+
function maoyingApi(path, body, timeoutMs) {
|
|
166
|
+
// timeoutMs 缺省用统一 60s;一键升级单独放宽到 200s(见常量处注释)。
|
|
167
|
+
var limit = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : API_TIMEOUT_MS;
|
|
160
168
|
var opts = body === undefined ? {} : {
|
|
161
169
|
method: "POST",
|
|
162
170
|
headers: { "content-type": "application/json" },
|
|
@@ -166,19 +174,25 @@ window.__ModuleLoader__.load({
|
|
|
166
174
|
var timer = null;
|
|
167
175
|
if (ctrl) {
|
|
168
176
|
opts.signal = ctrl.signal;
|
|
169
|
-
timer = setTimeout(function () { ctrl.abort(); },
|
|
177
|
+
timer = setTimeout(function () { ctrl.abort(); }, limit);
|
|
170
178
|
}
|
|
171
179
|
return fetch(API_BASE + path, opts).then(function (r) {
|
|
172
180
|
// 先取全文再 JSON.parse:代理错误页 / 空体不再抛 SyntaxError。
|
|
173
181
|
return r.text().then(function (raw) {
|
|
174
182
|
var d = null;
|
|
175
183
|
try { d = raw ? JSON.parse(raw) : null; } catch (err) { d = null; }
|
|
176
|
-
if (!r.ok)
|
|
184
|
+
if (!r.ok) {
|
|
185
|
+
// 错误对象带状态码:调用方按码分流(如设置保存只在 409 外部改动
|
|
186
|
+
// 冲突时重置表单,其余失败保留用户正在编辑的内容)。
|
|
187
|
+
var e = new Error((d && d.error) || ("HTTP " + r.status));
|
|
188
|
+
e.status = r.status;
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
177
191
|
if (!d) throw new Error(tf("api.notJson", { code: r.status }));
|
|
178
192
|
return d;
|
|
179
193
|
});
|
|
180
194
|
}).catch(function (err) {
|
|
181
|
-
if (ctrl && err && err.name === "AbortError") throw new Error(tf("api.timeout", { s:
|
|
195
|
+
if (ctrl && err && err.name === "AbortError") throw new Error(tf("api.timeout", { s: Math.round(limit / 1000) }));
|
|
182
196
|
throw err;
|
|
183
197
|
}).finally(function () {
|
|
184
198
|
if (timer !== null) clearTimeout(timer);
|
|
@@ -601,6 +615,35 @@ window.__ModuleLoader__.load({
|
|
|
601
615
|
if (d && d.current) setVer(d); // 常态也显示当前版本号;有新版再亮徽章
|
|
602
616
|
}).catch(function () {});
|
|
603
617
|
}, []);
|
|
618
|
+
// 一键升级状态机:""(可点)→ "installing"(安装中,按钮禁用)→
|
|
619
|
+
// "installed"(已装完待重启,按钮永久禁用、新版徽章收起)。可预期失败
|
|
620
|
+
// (dev-link / pnpm-missing / pnpm 出错)回落 "" 允许重试。
|
|
621
|
+
var _up = useState(""), upState = _up[0], setUpState = _up[1];
|
|
622
|
+
function runSelfUpdate() {
|
|
623
|
+
if (upState !== "") return;
|
|
624
|
+
setUpState("installing");
|
|
625
|
+
// 不走 apiPost:成功提示要带新版本号、失败按 kind 分流文案,且超时
|
|
626
|
+
// 单独放宽(服务端 pnpm 最长 180s,统一 60s 会先掐断,见常量处注释)。
|
|
627
|
+
maoyingApi("self-update", {}, SELF_UPDATE_TIMEOUT_MS).then(function (d) {
|
|
628
|
+
if (d && d.ok) {
|
|
629
|
+
setUpState("installed");
|
|
630
|
+
// 硬约束照实说:装完只是磁盘换新,运行中的进程仍是旧代码。
|
|
631
|
+
toast.ok(tf("toast.selfUpdate.ok", { ver: (d && d.to) || "?" }));
|
|
632
|
+
} else if (d && d.kind === "dev-link") {
|
|
633
|
+
setUpState("");
|
|
634
|
+
toast.warn(tt("toast.selfUpdate.devLink"));
|
|
635
|
+
} else if (d && d.kind === "pnpm-missing") {
|
|
636
|
+
setUpState("");
|
|
637
|
+
toast.warn(tt("toast.selfUpdate.pnpmMissing"));
|
|
638
|
+
} else {
|
|
639
|
+
setUpState("");
|
|
640
|
+
toast.err(tf("toast.prefix", { msg: (d && d.error) || tt("common.unknownErr") }));
|
|
641
|
+
}
|
|
642
|
+
}).catch(function (err) {
|
|
643
|
+
setUpState("");
|
|
644
|
+
toast.err(tf("toast.prefix", { msg: err.message }));
|
|
645
|
+
});
|
|
646
|
+
}
|
|
604
647
|
useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
|
|
605
648
|
useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
|
|
606
649
|
// 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
|
|
@@ -659,8 +702,22 @@ window.__ModuleLoader__.load({
|
|
|
659
702
|
h("h1", { className: "myt-title" }, "毛颖创作台",
|
|
660
703
|
ver && ver.current ? h("span", { className: "myt-verTag", title: tt("header.curVerTitle") }, ver.current) : null,
|
|
661
704
|
ver && ver.updateAvailable
|
|
662
|
-
?
|
|
663
|
-
|
|
705
|
+
? [
|
|
706
|
+
// 徽章(跳发布页)+「一键升级」按钮双元素;装完(installed)
|
|
707
|
+
// 徽章收起——磁盘已是新版,别再引导去发布页——按钮变
|
|
708
|
+
// 「已安装,待重启」保持禁用,重启 DeepSeek 换新进程后才消失。
|
|
709
|
+
upState !== "installed"
|
|
710
|
+
? h("a", { key: "b", className: "myt-verBadge", href: ver.releaseUrl || "#", target: "_blank", rel: "noreferrer", title: tf("header.upgradeTip", { cur: ver.current }) },
|
|
711
|
+
tf("header.newVersion", { ver: ver.latest }))
|
|
712
|
+
: null,
|
|
713
|
+
h("button", {
|
|
714
|
+
key: "u", type: "button", className: "myt-verUpgrade",
|
|
715
|
+
disabled: upState !== "",
|
|
716
|
+
title: upState === "installed" ? tt("header.upgradedTitle") : tt("header.upgradeTitle"),
|
|
717
|
+
onClick: runSelfUpdate,
|
|
718
|
+
},
|
|
719
|
+
upState === "installing" ? tt("header.upgrading") : upState === "installed" ? tt("header.upgraded") : tt("header.upgradeBtn")),
|
|
720
|
+
]
|
|
664
721
|
: null),
|
|
665
722
|
h("p", { className: "myt-subtitle" }, tt("header.subtitle"))),
|
|
666
723
|
h("div", { className: "myt-headActions" },
|
|
@@ -700,17 +757,50 @@ window.__ModuleLoader__.load({
|
|
|
700
757
|
warnings.map(function (w, i) { return h("div", { key: i }, "· " + String(w)); })));
|
|
701
758
|
}
|
|
702
759
|
|
|
760
|
+
// 静态体检黄条:checkProject 的原始问题清单(快照侧会把越枚举 status
|
|
761
|
+
// 归一成 pending,人工改坏在快照里看不出来,这里照 CLI check 同源透出)。
|
|
762
|
+
// 含 error 级问题时叠加强调 class(纯 CSS,见 base.css)。
|
|
763
|
+
function CheckStrip(problems) {
|
|
764
|
+
var errs = 0;
|
|
765
|
+
for (var i = 0; i < problems.length; i++) { if (problems[i] && problems[i].level === "error") errs++; }
|
|
766
|
+
return h("div", { className: "myt-strip myt-stripWarn" + (errs > 0 ? " myt-stripCheckErr" : "") },
|
|
767
|
+
h("span", { className: "myt-stripIcon" }, ic("IconWarningOutlineRegular", 14)),
|
|
768
|
+
h("span", { className: "myt-stripText" },
|
|
769
|
+
h("div", null, h("strong", null, tf("check.title", { n: fmtNum(problems.length), err: fmtNum(errs), warn: fmtNum(problems.length - errs) }))),
|
|
770
|
+
problems.slice(0, 8).map(function (p, i) {
|
|
771
|
+
return h("div", { key: i }, "· [" + String((p && p.level) || "") + "] " + String((p && p.file) || "") + ": " + String((p && p.message) || ""));
|
|
772
|
+
}),
|
|
773
|
+
h("div", null, tt("check.fullHint"))));
|
|
774
|
+
}
|
|
775
|
+
|
|
703
776
|
// ── 概览 tab:project 卡 + 统计卡 + 最近更新 ───────────────────────────
|
|
704
777
|
function OverviewTab(props) {
|
|
705
778
|
var snap = props.snap;
|
|
706
779
|
var reload = props.reload;
|
|
707
780
|
var gotoTab = props.gotoTab;
|
|
781
|
+
var wsPath = props.wsPath;
|
|
708
782
|
var project = snap.project || {};
|
|
709
783
|
var totals = snap.totals || {};
|
|
710
784
|
var byStatus = totals.byStatus || {};
|
|
711
785
|
var _b = useState(false), phaseBusy = _b[0], setPhaseBusy = _b[1];
|
|
712
786
|
var phaseIdx = PHASES.indexOf(project.phase);
|
|
713
787
|
var nextPhase = phaseIdx !== -1 && phaseIdx < PHASES.length - 1 ? PHASES[phaseIdx + 1] : null;
|
|
788
|
+
// 静态体检黄条:仅 found 时拉 check 路由(未初始化没有可查的东西)。
|
|
789
|
+
// 工作区切换与每次 reload 都换新 snap → effect 重跑即重查;请求序号防
|
|
790
|
+
// 过期响应(快速切工作区时在途旧响应不得落进新工作区的黄条)。
|
|
791
|
+
var _ck = useState(null), checkProblems = _ck[0], setCheckProblems = _ck[1];
|
|
792
|
+
var checkSeqRef = useRef(0);
|
|
793
|
+
useEffect(function () {
|
|
794
|
+
if (!snap || !snap.found || !wsPath) { setCheckProblems(null); return; }
|
|
795
|
+
var seq = ++checkSeqRef.current; // 先自增再发请求;早退分支也作废在途旧响应
|
|
796
|
+
apiGet("check", { path: wsPath }).then(function (d) {
|
|
797
|
+
if (seq !== checkSeqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
|
|
798
|
+
setCheckProblems(d && d.ok && Array.isArray(d.problems) ? d.problems : []);
|
|
799
|
+
}).catch(function () {
|
|
800
|
+
if (seq !== checkSeqRef.current) return;
|
|
801
|
+
setCheckProblems(null); // 拉取失败不显示黄条(toast 已由 apiGet 统一提示)
|
|
802
|
+
});
|
|
803
|
+
}, [snap, wsPath]);
|
|
714
804
|
function advancePhase() {
|
|
715
805
|
if (!nextPhase || phaseBusy) return;
|
|
716
806
|
setPhaseBusy(true);
|
|
@@ -718,6 +808,7 @@ window.__ModuleLoader__.load({
|
|
|
718
808
|
}
|
|
719
809
|
var recent = (snap.chapters || []).slice().sort(function (a, b) { return (b.num || 0) - (a.num || 0); }).slice(0, 5);
|
|
720
810
|
return h("div", { className: "myt-stack" },
|
|
811
|
+
checkProblems && checkProblems.length ? CheckStrip(checkProblems) : null,
|
|
721
812
|
h("div", { className: "myt-card myt-hero" },
|
|
722
813
|
h("div", { className: "myt-heroMeta" },
|
|
723
814
|
project.author ? h("span", { className: "myt-muted" }, tf("overview.author", { name: project.author })) : null,
|
|
@@ -876,6 +967,11 @@ window.__ModuleLoader__.load({
|
|
|
876
967
|
zh: {
|
|
877
968
|
"shell.title": "毛颖创作台", "header.subtitle": "网文项目可视化与状态流转 · 只读正文,写状态",
|
|
878
969
|
"header.curVerTitle": "当前版本", "header.upgradeTip": "当前 {cur},点击打开新版发布页", "header.newVersion": "有新版 {ver} ↗",
|
|
970
|
+
"header.upgradeBtn": "一键升级", "header.upgrading": "安装中…", "header.upgraded": "已安装,待重启",
|
|
971
|
+
"header.upgradeTitle": "用 pnpm 安装 npm 最新版(最长约 3 分钟),装完需重启 DeepSeek 生效",
|
|
972
|
+
"header.upgradedTitle": "已安装新版,重启 DeepSeek 后生效",
|
|
973
|
+
"toast.selfUpdate.ok": "已安装 {ver},重启 DeepSeek 后生效",
|
|
974
|
+
"toast.selfUpdate.devLink": "开发目录安装,请 git pull 更新", "toast.selfUpdate.pnpmMissing": "未找到 pnpm,请手动安装",
|
|
879
975
|
"header.workspace": "工作区", "header.wsAria": "选择工作区", "header.noNovel": "(无 novel)", "header.untitled": "未命名",
|
|
880
976
|
"header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
|
|
881
977
|
"tabs.aria": "创作台分区",
|
|
@@ -906,6 +1002,7 @@ window.__ModuleLoader__.load({
|
|
|
906
1002
|
"clue.clue": "线索", "clue.redHerring": "红鲱鱼", "clue.inference": "推理", "clue.question": "疑问",
|
|
907
1003
|
"sci.known": "已确证", "sci.extrapolation": "推演", "sci.hypothesis": "假说", "world.unlabeled": "未标级",
|
|
908
1004
|
"warnings.title": "部分文件解析异常(已跳过):",
|
|
1005
|
+
"check.title": "静态体检:{n} 项(错误 {err} / 警告 {warn})", "check.fullHint": "全量结果请在终端运行 maoying check 查看",
|
|
909
1006
|
"index.title": "索引板(clue-board / memories)", "index.rebuild": "重建索引", "index.rebuilding": "重建中…",
|
|
910
1007
|
"index.rebuildTip": "按 CLI index 同一逻辑重建 index.yml", "index.clueBoard": "线索板", "index.clueEmpty": "clue-board.yml 还没有条目",
|
|
911
1008
|
"index.memBoard": "记忆板", "index.memEmpty": "memories.yml 还没有条目", "index.promise": "承诺线",
|
|
@@ -949,6 +1046,11 @@ window.__ModuleLoader__.load({
|
|
|
949
1046
|
en: {
|
|
950
1047
|
"shell.title": "Maoying Studio", "header.subtitle": "Web-novel project dashboard & status flow · read-only text, writable status",
|
|
951
1048
|
"header.curVerTitle": "Current version", "header.upgradeTip": "Current {cur} — click to open the release page", "header.newVersion": "New version {ver} ↗",
|
|
1049
|
+
"header.upgradeBtn": "Upgrade now", "header.upgrading": "Installing…", "header.upgraded": "Installed — restart pending",
|
|
1050
|
+
"header.upgradeTitle": "Install the latest npm release with pnpm (up to ~3 min); takes effect after restarting DeepSeek",
|
|
1051
|
+
"header.upgradedTitle": "New version installed — takes effect after restarting DeepSeek",
|
|
1052
|
+
"toast.selfUpdate.ok": "Installed {ver} — takes effect after restarting DeepSeek",
|
|
1053
|
+
"toast.selfUpdate.devLink": "Installed from a dev directory — update with git pull", "toast.selfUpdate.pnpmMissing": "pnpm not found — install it manually",
|
|
952
1054
|
"header.workspace": "Workspace", "header.wsAria": "Select workspace", "header.noNovel": "(no novel)", "header.untitled": "Untitled",
|
|
953
1055
|
"header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
|
|
954
1056
|
"tabs.aria": "Studio sections",
|
|
@@ -979,6 +1081,7 @@ window.__ModuleLoader__.load({
|
|
|
979
1081
|
"clue.clue": "Clue", "clue.redHerring": "Red herring", "clue.inference": "Inference", "clue.question": "Question",
|
|
980
1082
|
"sci.known": "Confirmed", "sci.extrapolation": "Extrapolation", "sci.hypothesis": "Hypothesis", "world.unlabeled": "Unrated",
|
|
981
1083
|
"warnings.title": "Some files failed to parse (skipped):",
|
|
1084
|
+
"check.title": "Static check: {n} issue(s) ({err} errors / {warn} warnings)", "check.fullHint": "Run maoying check in a terminal for the full report",
|
|
982
1085
|
"index.title": "Index Board (clue-board / memories)", "index.rebuild": "Rebuild index", "index.rebuilding": "Rebuilding…",
|
|
983
1086
|
"index.rebuildTip": "Rebuild index.yml with the same logic as the CLI index command", "index.clueBoard": "Clue board", "index.clueEmpty": "No entries in clue-board.yml yet",
|
|
984
1087
|
"index.memBoard": "Memory board", "index.memEmpty": "No entries in memories.yml yet", "index.promise": "Promise thread",
|
|
@@ -1151,20 +1254,51 @@ window.__ModuleLoader__.load({
|
|
|
1151
1254
|
// ── 小说设置 tab:可视化 project.yml 平键并保存。mode/phase 走枚举
|
|
1152
1255
|
// 下拉,自由文本走输入框;保存走 project-save 路由(服务端校验 + 保
|
|
1153
1256
|
// 注释改写),成功后重拉快照让概览同步。 ──
|
|
1257
|
+
// 表单初值(与磁盘真值同源):首次载入与 409 重置共用同一形状。
|
|
1258
|
+
function settingsFormOf(project) {
|
|
1259
|
+
project = project || {};
|
|
1260
|
+
return {
|
|
1261
|
+
title: project.title || "", author: project.author || "", genre: project.genre || "",
|
|
1262
|
+
length: project.length || "", language: project.language || "zh",
|
|
1263
|
+
phase: PHASES.indexOf(project.phase) !== -1 ? project.phase : "idea",
|
|
1264
|
+
mode: project.mode || "ai-draft",
|
|
1265
|
+
description: project.description || "",
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
// 快照 root 与工作区 path 同源但形态可能有差(尾斜杠等),比对前归一。
|
|
1269
|
+
function normRoot(s) { return String(s || "").replace(/[\\/]+$/, ""); }
|
|
1154
1270
|
function SettingsTab(props) {
|
|
1155
1271
|
var snap = props.snap;
|
|
1156
1272
|
var wsPath = props.wsPath;
|
|
1157
1273
|
var reload = props.reload;
|
|
1158
1274
|
var project = snap.project || {};
|
|
1159
|
-
var _f = useState(function () {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1275
|
+
var _f = useState(function () { return settingsFormOf(project); }), form = _f[0], setForm = _f[1];
|
|
1276
|
+
// 保存基线:表单载入时的 project.yml 指纹(snap.project_hash)。服务端
|
|
1277
|
+
// 拿它检测表单载入后的外部改动(作者手改 / agent 推进 phase),命中即
|
|
1278
|
+
// 409 拒写——没有它,保存会静默覆盖同名键、丢掉人工改动。
|
|
1279
|
+
var baseHashRef = useRef(snap && typeof snap.project_hash === "string" ? snap.project_hash : null);
|
|
1280
|
+
// 409 后的一次性重置标记:reload 带回新快照时把表单与基线放回磁盘真值。
|
|
1281
|
+
// 只在保存失败路径置位——顶栏刷新等其他 reload 不碰正在编辑的表单。
|
|
1282
|
+
var pendingResetRef = useRef(false);
|
|
1283
|
+
useEffect(function () {
|
|
1284
|
+
var hash = snap && typeof snap.project_hash === "string" ? snap.project_hash : null;
|
|
1285
|
+
if (pendingResetRef.current) {
|
|
1286
|
+
pendingResetRef.current = false;
|
|
1287
|
+
setForm(settingsFormOf(snap && snap.project));
|
|
1288
|
+
baseHashRef.current = hash;
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
// 空闲跟随:快照确属当前工作区(root 对得上,跨工作区切换的在途旧
|
|
1292
|
+
// 快照不认)且表单未被用户动过(与磁盘逐键同值)时,把基线对齐本快照
|
|
1293
|
+
// ——本组件不随工作区切换重挂,基线若还攥着旧工作区的 hash,保存必被
|
|
1294
|
+
// 自家 409 拦住(假冲突)。用户已编辑则不打扰,外部改动由保存 409 兜底。
|
|
1295
|
+
if (!snap || !snap.found || normRoot(snap.root) !== normRoot(wsPath)) return;
|
|
1296
|
+
var disk = settingsFormOf(snap.project);
|
|
1297
|
+
for (var k in disk) {
|
|
1298
|
+
if (String(form[k]) !== String(disk[k])) return;
|
|
1299
|
+
}
|
|
1300
|
+
baseHashRef.current = hash;
|
|
1301
|
+
}, [snap, wsPath]);
|
|
1168
1302
|
var _b = useState(false), busy = _b[0], setBusy = _b[1];
|
|
1169
1303
|
var _e = useState(""), saveErr = _e[0], setSaveErr = _e[1];
|
|
1170
1304
|
function set(k, v) { setForm(Object.assign({}, form, (function () { var o = {}; o[k] = v; return o; })())); }
|
|
@@ -1174,10 +1308,20 @@ window.__ModuleLoader__.load({
|
|
|
1174
1308
|
setBusy(true); setSaveErr("");
|
|
1175
1309
|
// 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast);
|
|
1176
1310
|
// 卡内 saveErr 只承担表单旁的错误态。
|
|
1177
|
-
apiPost("project-save", { path: wsPath, values: form }).then(function () {
|
|
1311
|
+
apiPost("project-save", { path: wsPath, values: form, base_hash: baseHashRef.current }).then(function (res) {
|
|
1312
|
+
// 写后指纹即下一轮基线:不等 reload 快照就先接住——连续两次保存时
|
|
1313
|
+
// 第二次若还回传载入时的旧指纹,会被自家 409 拦住(假冲突)。
|
|
1314
|
+
if (res && typeof res.project_hash === "string") baseHashRef.current = res.project_hash;
|
|
1178
1315
|
return reload();
|
|
1179
1316
|
}).catch(function (err) {
|
|
1180
1317
|
setSaveErr(err.message);
|
|
1318
|
+
// 409 = project.yml 在表单载入后被外部改过:重拉快照并一次性把表单
|
|
1319
|
+
// 放回磁盘真值,别让用户在过期基线上继续盲改。其他失败(网络抖动
|
|
1320
|
+
// 等)不动表单,保留正在编辑的内容。
|
|
1321
|
+
if (err && err.status === 409) {
|
|
1322
|
+
pendingResetRef.current = true;
|
|
1323
|
+
reload();
|
|
1324
|
+
}
|
|
1181
1325
|
}).finally(function () { setBusy(false); });
|
|
1182
1326
|
}
|
|
1183
1327
|
function field(label, key, placeholder, textarea) {
|
package/lib/panel-core.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* (五期起含 outlines 章纲清单)
|
|
12
12
|
* readChapter(novelDir, file) 章节正文全文(抽屉用;五期起带对应章纲
|
|
13
13
|
* outline 字段)
|
|
14
|
+
* projectFileHash(novelDir) project.yml 原文 sha256 hex(快照
|
|
15
|
+
* project_hash / 保存旧版检测共用基线)
|
|
14
16
|
*
|
|
15
17
|
* 写入面(全部「读原文 → 行级字符串手术 → 写后校验替换计数 === 1」;
|
|
16
18
|
* 计数不符或目标行缺失一律 409 拒写不落盘,把对 git diff 的扰动压到最小)
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
* 绝不让它炸掉整个快照。
|
|
29
31
|
*/
|
|
30
32
|
|
|
33
|
+
import { createHash } from 'node:crypto'
|
|
31
34
|
import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
32
35
|
import path from 'node:path'
|
|
33
36
|
|
|
@@ -397,6 +400,27 @@ function readProject(novelDir, yamlLib, warnings) {
|
|
|
397
400
|
}
|
|
398
401
|
}
|
|
399
402
|
|
|
403
|
+
/** sha256 hex(小写):文本 → 指纹。 */
|
|
404
|
+
function sha256Hex(text) {
|
|
405
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* project.yml 原文指纹(sha256 hex):面板「人工改动保护」的基线。快照带出
|
|
410
|
+
* project_hash,保存时回传 base_hash 比对——表单载入后文件被外部(作者手改
|
|
411
|
+
* / agent 推进 phase)动过就能查觉,拒绝静默覆盖。读不到/读失败返回 null
|
|
412
|
+
* (与「无 project.yml」同口径,不炸快照)。
|
|
413
|
+
*/
|
|
414
|
+
export function projectFileHash(novelDir) {
|
|
415
|
+
let text = null
|
|
416
|
+
try {
|
|
417
|
+
text = readFileSync(path.join(path.resolve(novelDir), 'project.yml'), 'utf8')
|
|
418
|
+
} catch {
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
return sha256Hex(text)
|
|
422
|
+
}
|
|
423
|
+
|
|
400
424
|
/**
|
|
401
425
|
* 全量快照(docs/creator-panel.md §3,字段名冻结)。novelDir 为工作区的
|
|
402
426
|
* novel 目录;project.yml 不在 = 未初始化(found: false,其余字段缺省)。
|
|
@@ -409,10 +433,13 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
409
433
|
const warnings = []
|
|
410
434
|
|
|
411
435
|
if (!existsSync(path.join(dir, 'project.yml'))) {
|
|
412
|
-
return { ok: true, root, novelDir: dir, found: false, warnings }
|
|
436
|
+
return { ok: true, root, novelDir: dir, found: false, project_hash: null, warnings }
|
|
413
437
|
}
|
|
414
438
|
|
|
415
439
|
const project = readProject(dir, yamlLib, warnings)
|
|
440
|
+
// 人工改动保护基线:快照顶层带 project.yml 原文指纹,客户端保存时原样回传
|
|
441
|
+
// (project-save 的 base_hash);读不到为 null(不影响快照其余字段)。
|
|
442
|
+
const projectHash = projectFileHash(dir)
|
|
416
443
|
|
|
417
444
|
const chaptersRaw = (() => {
|
|
418
445
|
try {
|
|
@@ -451,6 +478,15 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
451
478
|
markers: strArray(meta.markers),
|
|
452
479
|
}))
|
|
453
480
|
|
|
481
|
+
// 关系端点归一:契约规定 relations.yml 的 a/b 是角色 slug(characters/ 目录名),
|
|
482
|
+
// 而关系图客户端按角色 name(卡 frontmatter name,缺省回落 slug)找节点——不出
|
|
483
|
+
// 快照侧归一,带中文名的角色卡必然端点对不上、一条边都画不出。对得上角色卡的
|
|
484
|
+
// 端点改写成 name;对不上的(已死亡/未建档角色,或历史条目直接写了 name)原样
|
|
485
|
+
// 保留。novel-parse 侧(protagonistCards、relations 校验)仍按 slug 契约,不动。
|
|
486
|
+
const nameBySlug = Object.fromEntries(
|
|
487
|
+
characters.map((c) => [String(c.file ?? '').replace(/\.md$/i, ''), c.name])
|
|
488
|
+
)
|
|
489
|
+
|
|
454
490
|
const relations = collectArrayYml(dir, 'relations.yml', yamlLib, warnings).map((r) => ({
|
|
455
491
|
a: strOrNull(r.a),
|
|
456
492
|
b: strOrNull(r.b),
|
|
@@ -459,7 +495,7 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
459
495
|
intensity: intOrNull(r.intensity),
|
|
460
496
|
started: strOrNull(r.started),
|
|
461
497
|
ended: strOrNull(r.ended),
|
|
462
|
-
}))
|
|
498
|
+
})).map((r) => ({ ...r, a: nameBySlug[r.a] ?? r.a, b: nameBySlug[r.b] ?? r.b }))
|
|
463
499
|
|
|
464
500
|
const foreshadows = collectArrayYml(dir, 'foreshadows.yml', yamlLib, warnings).map((f) => ({
|
|
465
501
|
id: strOrNull(f.id),
|
|
@@ -531,6 +567,7 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
531
567
|
root,
|
|
532
568
|
novelDir: dir,
|
|
533
569
|
found: true,
|
|
570
|
+
project_hash: projectHash,
|
|
534
571
|
project,
|
|
535
572
|
chapters,
|
|
536
573
|
characters,
|
|
@@ -683,8 +720,14 @@ const PROJECT_MODE_KEYS = ['ai-draft', 'co-write', 'author-draft']
|
|
|
683
720
|
* 「单行值替换、行尾注释原样保留」(与 mutatePhase 同一保注释刀法);文件里
|
|
684
721
|
* 没有的键按模板顺序补在末尾(带模板注释)。校验:phase/mode 越枚举 422、
|
|
685
722
|
* title 空白 422。值统一经 yamlScalar 规范成可回读的 YAML 标量。
|
|
723
|
+
*
|
|
724
|
+
* 人工改动保护:options.baseHash 是面板表单载入时的 project.yml 原文指纹
|
|
725
|
+
* (快照 project_hash)。与写入前现读原文不一致 → 表单基线已过期(作者或
|
|
726
|
+
* agent 在载入后改过文件),409 拒写——静默覆盖会丢人工改动。未传 baseHash
|
|
727
|
+
* 跳过检查(旧调用方兼容)。返回值带写后新指纹 project_hash,客户端 reload
|
|
728
|
+
* 前即可拿到下一轮基线。
|
|
686
729
|
*/
|
|
687
|
-
export function mutateProject(novelDir, values) {
|
|
730
|
+
export function mutateProject(novelDir, values, options = {}) {
|
|
688
731
|
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
|
689
732
|
fail(422, 'values 必须是键值对象')
|
|
690
733
|
}
|
|
@@ -701,6 +744,12 @@ export function mutateProject(novelDir, values) {
|
|
|
701
744
|
|
|
702
745
|
const target = path.join(novelDir, 'project.yml')
|
|
703
746
|
const text = readTarget(target, 'project.yml')
|
|
747
|
+
// 旧版检测在读原文之后、落笔之前:指纹不一致就地 409,一个字节都不动。
|
|
748
|
+
// 客户端指纹归一到小写再比(指纹本身 hex,大小写宽容不构成安全口子)。
|
|
749
|
+
const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
|
|
750
|
+
if (baseHash !== '' && baseHash !== sha256Hex(text)) {
|
|
751
|
+
fail(409, 'project.yml 已被外部修改,请刷新面板后基于最新内容重改再存')
|
|
752
|
+
}
|
|
704
753
|
const lines = text.split(/\r?\n/)
|
|
705
754
|
const pending = new Map(Object.entries(next))
|
|
706
755
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -724,8 +773,10 @@ export function mutateProject(novelDir, values) {
|
|
|
724
773
|
lines.splice(insertAt, 0, ...missing)
|
|
725
774
|
}
|
|
726
775
|
}
|
|
727
|
-
|
|
728
|
-
|
|
776
|
+
const nextText = joinLines(lines, text)
|
|
777
|
+
writeAtomic(target, nextText)
|
|
778
|
+
// 返回写后指纹:客户端下一轮保存的 base_hash 直接取它,不必再等一次快照。
|
|
779
|
+
return { ok: true, project_hash: sha256Hex(nextText) }
|
|
729
780
|
}
|
|
730
781
|
|
|
731
782
|
/** YAML 平标量:安全字符裸写,含特殊字符(冒号空格/井号/引号/首尾空格等)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板内一键升级 —— 直接跑 pnpm 把本包装到当前 DSH profile(创作台面板用)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:desktop 档的真实安装形态是 <dshHome>/profiles/<profile>/node_modules/
|
|
5
|
+
* dsh-maoying(真实目录,非 symlink),宿主 dsh CLI 拒绝管理 desktop 档,唯一
|
|
6
|
+
* 可行的升级命令是直接 pnpm:`pnpm --dir <profileDir> add dsh-maoying@latest`。
|
|
7
|
+
*
|
|
8
|
+
* 安全边界(缺一不可,全不满足绝不执行安装):
|
|
9
|
+
* 1) 安装形态判定——插件包自身目录先 realpathSync 取真路径,真路径 ≠ 常规
|
|
10
|
+
* 路径即 link/symlink 安装(如 web 档指向 dev repo)→ 拒绝(dev-link,
|
|
11
|
+
* 请在开发目录 git pull);真路径不匹配 <dshHome>/profiles/<profile>/
|
|
12
|
+
* node_modules/dsh-maoying 结构 → 拒绝(unsupported-layout)。dshHome /
|
|
13
|
+
* profile 取 env.DSH_HOME / DSH_PROFILE(~ 前缀手动展开),缺失时回退从
|
|
14
|
+
* 真路径的 profiles 祖先推导,推导不出同样拒绝。
|
|
15
|
+
* 2) pnpm 解析——按候选路径逐个 existsSync + accessSync(X_OK)(PATH 各目录
|
|
16
|
+
* 在前,homebrew / pnpm 官方安装位在后),一个都不可执行 → 拒绝
|
|
17
|
+
* (pnpm-missing)。绝不用 shell:true,无注入面。
|
|
18
|
+
* 3) 超时 180 秒——超时 kill 子进程收敛为 timeout,不挂死面板请求。
|
|
19
|
+
*
|
|
20
|
+
* 所有失败路径都不抛错,一律收敛为 { ok: false, kind, message } 结果对象;
|
|
21
|
+
* 成功返回 { ok: true, kind: 'installed', from, to, restartRequired: true }。
|
|
22
|
+
* 硬约束:装完后运行中的进程仍是旧代码,必须重启 DeepSeek 才生效——restart-
|
|
23
|
+
* Required 恒 true,UI 文案必须明说,不得暗示装完即生效。依赖全部可注入
|
|
24
|
+
* (spawnImpl / realpathImpl / existsImpl / readImpl …),对齐 lib/version.mjs
|
|
25
|
+
* 的 fetchImpl 模式,方便测试。
|
|
26
|
+
*/
|
|
27
|
+
import { spawn as nodeSpawn } from 'node:child_process'
|
|
28
|
+
import { accessSync, constants as fsConstants, existsSync, realpathSync } from 'node:fs'
|
|
29
|
+
import { readFile } from 'node:fs/promises'
|
|
30
|
+
import os from 'node:os'
|
|
31
|
+
import path from 'node:path'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
|
|
34
|
+
import { invalidateVersionCache } from './version.mjs'
|
|
35
|
+
|
|
36
|
+
/** 安装超时:pnpm 冷缓存 + 国内网络最坏留足 3 分钟。 */
|
|
37
|
+
export const SELF_UPDATE_TIMEOUT_MS = 180 * 1000
|
|
38
|
+
/** 失败信息里保留的子进程输出尾部字节数(stdout / stderr 各一份)。 */
|
|
39
|
+
const OUTPUT_TAIL_BYTES = 2048
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 插件包自身目录(package.json 所在目录)。抽成函数便于测试注入假安装位。
|
|
43
|
+
*/
|
|
44
|
+
function defaultPkgDir() {
|
|
45
|
+
return path.dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* PATH 里 pnpm 之后的候选兜底(macOS 常见安装位;GUI 进程 PATH 常缺 pnpm,
|
|
50
|
+
* 宿主进程又不经过 shell,PATH 找不到时按固定路径逐个试)。
|
|
51
|
+
*/
|
|
52
|
+
function extraPnpmCandidates(env) {
|
|
53
|
+
const home = typeof env.HOME === 'string' && env.HOME.trim() !== '' ? env.HOME.trim() : os.homedir()
|
|
54
|
+
return [
|
|
55
|
+
'/opt/homebrew/bin/pnpm',
|
|
56
|
+
'/usr/local/bin/pnpm',
|
|
57
|
+
path.join(home, '.local', 'share', 'pnpm', 'pnpm'),
|
|
58
|
+
path.join(home, 'Library', 'pnpm', 'pnpm'),
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 解析 pnpm 可执行文件:PATH 各目录在前、常见安装位兜底,去重后逐个可执行性检查。 */
|
|
63
|
+
function resolvePnpm(env, { existsImpl, accessImpl }) {
|
|
64
|
+
const pathEnv = typeof env.PATH === 'string' ? env.PATH : ''
|
|
65
|
+
const fromPath = pathEnv.split(path.delimiter).filter((d) => d !== '').map((d) => path.join(d, 'pnpm'))
|
|
66
|
+
const candidates = [...new Set([...fromPath, ...extraPnpmCandidates(env)])]
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
try {
|
|
69
|
+
if (!existsImpl(candidate)) continue
|
|
70
|
+
accessImpl(candidate, fsConstants.X_OK)
|
|
71
|
+
return candidate
|
|
72
|
+
} catch {
|
|
73
|
+
/* 存在但不可执行(或探测异常):换下一个候选 */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 从真路径的 profiles 祖先推导 { home, profile }:真路径必须形如
|
|
81
|
+
* <dshHome>/profiles/<profile>/node_modules/dsh-maoying,其余形态返回 null。
|
|
82
|
+
*/
|
|
83
|
+
function deriveLayoutFromPath(realDir) {
|
|
84
|
+
const pkgName = path.basename(realDir)
|
|
85
|
+
const nmDir = path.dirname(realDir)
|
|
86
|
+
if (pkgName !== 'dsh-maoying' || path.basename(nmDir) !== 'node_modules') return null
|
|
87
|
+
const profile = path.basename(path.dirname(nmDir))
|
|
88
|
+
const profilesDir = path.dirname(path.dirname(nmDir))
|
|
89
|
+
if (path.basename(profilesDir) !== 'profiles') return null
|
|
90
|
+
return { home: path.dirname(profilesDir), profile }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** env 里的 dsh home(~ 前缀手动展开,环境变量不经过 shell 展开)。 */
|
|
94
|
+
function dshHomeFromEnv(env) {
|
|
95
|
+
const raw = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
|
|
96
|
+
if (raw === '') return null
|
|
97
|
+
if (raw === '~' || raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(1))
|
|
98
|
+
return raw
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 读安装目录 package.json 的 version;读不到(缺失 / 解析失败 / 字段异常)
|
|
103
|
+
* 返回 null——from 缺失不拦安装(pnpm 退出码才是成败信号),to 缺失照常
|
|
104
|
+
* ok:true(version 显示为 null,前端兜底显示)。
|
|
105
|
+
*/
|
|
106
|
+
async function readPkgVersion(pkgJsonPath, readImpl) {
|
|
107
|
+
try {
|
|
108
|
+
const raw = await readImpl(pkgJsonPath)
|
|
109
|
+
const pkg = JSON.parse(raw)
|
|
110
|
+
return typeof pkg?.version === 'string' && pkg.version !== '' ? pkg.version : null
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 组装最小注入面:缺省走真实 Node 实现,测试逐项替换。 */
|
|
117
|
+
function normalizeDeps(opts) {
|
|
118
|
+
const env = opts.env ?? process.env
|
|
119
|
+
return {
|
|
120
|
+
env,
|
|
121
|
+
spawnImpl: opts.spawnImpl ?? nodeSpawn,
|
|
122
|
+
pkgDirImpl: opts.pkgDirImpl ?? defaultPkgDir,
|
|
123
|
+
realpathImpl: opts.realpathImpl ?? ((p) => realpathSync(p)),
|
|
124
|
+
existsImpl: opts.existsImpl ?? ((p) => existsSync(p)),
|
|
125
|
+
accessImpl: opts.accessImpl ?? ((p, mode) => accessSync(p, mode)),
|
|
126
|
+
readImpl:
|
|
127
|
+
opts.readImpl ??
|
|
128
|
+
((p) => readFile(p, 'utf8')),
|
|
129
|
+
logger: opts.logger ?? console,
|
|
130
|
+
timeoutMs:
|
|
131
|
+
typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0 ? opts.timeoutMs : SELF_UPDATE_TIMEOUT_MS,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* selfUpdate 主体(永不 reject;concurrency 去重与兜底 try/catch 在外层)。
|
|
137
|
+
* @returns {Promise<{ok: boolean, kind: string, from?: string|null, to?: string|null,
|
|
138
|
+
* restartRequired?: boolean, message?: string}>}
|
|
139
|
+
*/
|
|
140
|
+
async function runSelfUpdate(opts = {}) {
|
|
141
|
+
const { spawnImpl, pkgDirImpl, realpathImpl, existsImpl, accessImpl, readImpl, env, logger, timeoutMs } =
|
|
142
|
+
normalizeDeps(opts)
|
|
143
|
+
|
|
144
|
+
// ── a) 安装形态判定 ──────────────────────────────────────────────────────
|
|
145
|
+
const pkgDir = pkgDirImpl()
|
|
146
|
+
let realDir
|
|
147
|
+
try {
|
|
148
|
+
realDir = path.resolve(realpathImpl(pkgDir))
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return { ok: false, kind: 'unsupported-layout', message: `安装目录读取失败: ${err?.message ?? err}` }
|
|
151
|
+
}
|
|
152
|
+
if (realDir !== path.resolve(pkgDir)) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
kind: 'dev-link',
|
|
156
|
+
message: '当前安装指向开发目录(symlink/link 安装),请到开发目录 git pull 更新,这里不代装',
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const derived = deriveLayoutFromPath(realDir)
|
|
160
|
+
const home = dshHomeFromEnv(env) ?? derived?.home ?? null
|
|
161
|
+
const profileRaw = typeof env.DSH_PROFILE === 'string' ? env.DSH_PROFILE.trim() : ''
|
|
162
|
+
const profile = profileRaw !== '' ? profileRaw : derived?.profile ?? null
|
|
163
|
+
const profileDir = home && profile ? path.join(home, 'profiles', profile) : null
|
|
164
|
+
if (!derived || !profileDir || realDir !== path.resolve(path.join(profileDir, 'node_modules', 'dsh-maoying'))) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
kind: 'unsupported-layout',
|
|
168
|
+
message: '当前安装形态不受面板内升级支持(不是 <dshHome>/profiles/<profile>/node_modules/dsh-maoying 目录安装),请用命令行升级',
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── b) 解析 pnpm ────────────────────────────────────────────────────────
|
|
173
|
+
const pnpmPath = resolvePnpm(env, { existsImpl, accessImpl })
|
|
174
|
+
if (!pnpmPath) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
kind: 'pnpm-missing',
|
|
178
|
+
message: '未找到 pnpm 可执行文件(PATH 与常见安装路径都没有),请先安装 pnpm 后重试',
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pkgJsonPath = path.join(realDir, 'package.json')
|
|
183
|
+
const from = await readPkgVersion(pkgJsonPath, readImpl)
|
|
184
|
+
|
|
185
|
+
// ── c) 执行安装(绝不用 shell:true,无注入面)────────────────────────────
|
|
186
|
+
const exitInfo = await new Promise((resolve) => {
|
|
187
|
+
let child
|
|
188
|
+
try {
|
|
189
|
+
child = spawnImpl(pnpmPath, ['--dir', profileDir, 'add', 'dsh-maoying@latest'], {
|
|
190
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
191
|
+
})
|
|
192
|
+
} catch (err) {
|
|
193
|
+
resolve({ failed: true, kind: 'spawn-error', message: `pnpm 启动失败: ${err?.message ?? err}` })
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
let out = Buffer.alloc(0)
|
|
197
|
+
let errBuf = Buffer.alloc(0)
|
|
198
|
+
const keepTail = (buf, chunk) => Buffer.concat([buf, Buffer.from(chunk)]).subarray(-OUTPUT_TAIL_BYTES)
|
|
199
|
+
child.stdout?.on?.('data', (chunk) => { out = keepTail(out, chunk) })
|
|
200
|
+
child.stderr?.on?.('data', (chunk) => { errBuf = keepTail(errBuf, chunk) })
|
|
201
|
+
// 定时器只在进程仍存活时触发;exit 先到则清掉,不留挂起句柄。
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
try { child.kill() } catch { /* 已退出:忽略 */ }
|
|
204
|
+
resolve({
|
|
205
|
+
failed: true,
|
|
206
|
+
kind: 'timeout',
|
|
207
|
+
message: `pnpm 安装超时(${Math.round(timeoutMs / 1000)} 秒)已中止,请检查网络后重试`,
|
|
208
|
+
})
|
|
209
|
+
}, timeoutMs)
|
|
210
|
+
const onExit = (code, signal) => {
|
|
211
|
+
clearTimeout(timer)
|
|
212
|
+
if (code === 0) {
|
|
213
|
+
resolve({ failed: false })
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const tailText = `${out.toString('utf8')}\n${errBuf.toString('utf8')}`.trim()
|
|
217
|
+
resolve({
|
|
218
|
+
failed: true,
|
|
219
|
+
kind: 'pnpm-failed',
|
|
220
|
+
message: `pnpm add 失败(${signal != null ? `signal ${signal}` : `exit ${code}`}):${tailText !== '' ? tailText : '无输出'}`,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
child.on?.('exit', onExit)
|
|
224
|
+
child.on?.('error', (err) => {
|
|
225
|
+
clearTimeout(timer)
|
|
226
|
+
resolve({ failed: true, kind: 'spawn-error', message: `pnpm 启动失败: ${err?.message ?? err}` })
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
if (exitInfo.failed) {
|
|
230
|
+
logger?.warn?.(`maoying 创作台: self-update 失败(${exitInfo.kind}): ${exitInfo.message}`)
|
|
231
|
+
return { ok: false, kind: exitInfo.kind, message: exitInfo.message }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── d) 成功后重读新版本 + 版本缓存失效 ───────────────────────────────────
|
|
235
|
+
const to = await readPkgVersion(pkgJsonPath, readImpl)
|
|
236
|
+
// 装的是磁盘上的新包,进程内 update-check 的 1h 缓存已是旧结论,立刻失效,
|
|
237
|
+
// 下次打开面板(重启后)按磁盘新版本重算 updateAvailable。
|
|
238
|
+
invalidateVersionCache()
|
|
239
|
+
logger?.info?.(`maoying 创作台: self-update 完成 ${from ?? '?'} → ${to ?? '?'}(重启 DeepSeek 后生效)`)
|
|
240
|
+
return { ok: true, kind: 'installed', from, to, restartRequired: true }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// in-flight 去重:并发的 selfUpdate(连点按钮 / 双面板)复用同一次安装
|
|
244
|
+
// promise,pnpm add 绝不并发跑两份;promise 落定即清空。resetSelfUpdateInflight
|
|
245
|
+
// 仅供测试隔离模块级状态。
|
|
246
|
+
let inflight = null
|
|
247
|
+
|
|
248
|
+
/** 测试专用:清空模块级 in-flight 状态(生产勿用)。 */
|
|
249
|
+
export function resetSelfUpdateInflight() {
|
|
250
|
+
inflight = null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 面板一键升级入口(并发去重)。
|
|
255
|
+
* @returns {Promise<{ok: boolean, kind: string, ...}>} 永不 reject
|
|
256
|
+
*/
|
|
257
|
+
export async function selfUpdate(opts = {}) {
|
|
258
|
+
if (inflight) return inflight
|
|
259
|
+
inflight = runSelfUpdate(opts).finally(() => {
|
|
260
|
+
inflight = null
|
|
261
|
+
})
|
|
262
|
+
return inflight
|
|
263
|
+
}
|
package/lib/version.mjs
CHANGED
|
@@ -310,6 +310,17 @@ async function detectLatest({ fetchImpl, timeoutMs, nowMs }) {
|
|
|
310
310
|
// 注意并发窗口内各调用共享第一次调用注入的 now 语义(checkedAt 取先到者)。
|
|
311
311
|
let inflight = null
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* 版本缓存失效(面板一键升级专用,lib/self-update.mjs 调用):pnpm 把磁盘
|
|
315
|
+
* 上的包装到新版本后,进程内这份缓存(含 1h TTL 与 7 天 lastGood 窗口里的
|
|
316
|
+
* 旧结论)整体作废——下次 getVersionInfo 按磁盘新版本重算 updateAvailable。
|
|
317
|
+
* 只重置缓存与 in-flight,不改任何检测行为。
|
|
318
|
+
*/
|
|
319
|
+
export function invalidateVersionCache() {
|
|
320
|
+
cache = { at: 0, info: null }
|
|
321
|
+
inflight = null
|
|
322
|
+
}
|
|
323
|
+
|
|
313
324
|
/**
|
|
314
325
|
* 组合结果(面板路由专用):带 1 小时内存缓存,过期才重新请求;请求失败时
|
|
315
326
|
* 若上次成功结果在 lastGood 窗口(7 天,见 LASTGOOD_TTL_MS)内则原样复用
|