@cloud411716/fancy-webnovel 2.2.0 → 3.0.3
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 +65 -0
- package/client.js +201 -15
- package/cordis.patch.yml +5 -1
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +12 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +31 -7
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +145 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# 工作区 + MCP 架构
|
|
2
|
+
|
|
3
|
+
> 分支:`refactor/workspace-mcp`。这是 v3 架构说明;旧版“每个项目一个目录 + 嵌入式 SurrealDB”不再使用,也不做自动迁移。
|
|
4
|
+
|
|
5
|
+
## 1. 目录布局
|
|
6
|
+
|
|
7
|
+
第一次运行 `/fancy-bootstrap` 时由用户选择工作区 `A`:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
A/
|
|
11
|
+
.fancy/
|
|
12
|
+
workspace.json # 工作区 ID、SurrealDB 端点、当前项目
|
|
13
|
+
secrets.json # SurrealDB root/项目用户密码(0600)
|
|
14
|
+
surreal/
|
|
15
|
+
data/ # SurrealDB 数据目录
|
|
16
|
+
server.log
|
|
17
|
+
server.pid
|
|
18
|
+
bin/surreal # /fancy-db install 下载的二进制(可选)
|
|
19
|
+
trash/ # 删除项目时的默认归档位置
|
|
20
|
+
projects/
|
|
21
|
+
<uuid>/ # 一个小说项目
|
|
22
|
+
.fancy-project.json # UUID、数据库名、数据库用户、工作区路径
|
|
23
|
+
导出/ # fancy-export 输出
|
|
24
|
+
设定/ # 项目本地资料(导入/兼容用)
|
|
25
|
+
扫榜结果/ # 多个项目共享的扫榜产物
|
|
26
|
+
参考资料/ # 待拆解的对标小说(fancy-analyze 读取)
|
|
27
|
+
对标/ # 多个项目共享的对标分析
|
|
28
|
+
references/ # 共享写作参考
|
|
29
|
+
.fancy-deployed # 扫榜/选题阶段状态
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
关键点:
|
|
33
|
+
|
|
34
|
+
- 项目目录名是 UUID;小说名不参与目录名。
|
|
35
|
+
- 项目标题只存在数据库(注册表 + `story:main`),改名不会移动目录。
|
|
36
|
+
- `fancy-scan`、`fancy-analyze` 的产物在工作区根目录,多个项目共享。
|
|
37
|
+
- 项目目录里的 `.fancy-project.json` 只保存连接信息,不保存小说名。
|
|
38
|
+
|
|
39
|
+
## 2. 数据库模型
|
|
40
|
+
|
|
41
|
+
一个工作区一个 SurrealDB server,所有项目共用:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
namespace: fancy
|
|
45
|
+
database: fancy_registry # 项目注册表
|
|
46
|
+
project:<id> { project_id, name, db_name, db_user, dir, status, topic }
|
|
47
|
+
database: material_library # 工作区级素材库,所有项目共享
|
|
48
|
+
material, material_relation
|
|
49
|
+
material_search # 中文 ngram 全文索引
|
|
50
|
+
database: proj_<uuid> # 每个项目一个库
|
|
51
|
+
story, scale, chapter, outline, ...
|
|
52
|
+
fn::fancy::validate(...) # 数据库内置校验函数
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
项目用户:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
用户名:u_<uuid 前 16 位>
|
|
59
|
+
角色:VIEWER
|
|
60
|
+
权限:只能访问自己的 database;数据表 ALTER TABLE ... PERMISSIONS FOR select/create/update/delete FULL
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- 项目用户不能 `DEFINE / REMOVE` 表、函数、事件。
|
|
64
|
+
- schema、事件、校验函数由插件 root 会话创建。
|
|
65
|
+
- root 密码和项目用户密码在 `A/.fancy/secrets.json`,文件权限 0600;不要把该文件提交到 git。
|
|
66
|
+
- 默认只绑定 `127.0.0.1:8000`。需要远程访问时自行改 `workspace.json` 的 `surreal.bind/url`,并配置 TLS/防火墙。
|
|
67
|
+
|
|
68
|
+
## 3. 命令
|
|
69
|
+
|
|
70
|
+
### 工作区
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
/fancy-bootstrap 交互选择工作区并初始化
|
|
74
|
+
/fancy-bootstrap <目录> 指定工作区
|
|
75
|
+
/fancy-bootstrap status 查看工作区和服务
|
|
76
|
+
/fancy-bootstrap list 列出本机记录的所有工作区
|
|
77
|
+
/fancy-bootstrap help
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
初始化会创建目录、密钥、注册表、数据库校验函数和素材库 schema(`material_library`)。没有 `surreal` 时会尝试下载到 `A/.fancy/bin/`。
|
|
81
|
+
|
|
82
|
+
### 数据库
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
/fancy-db status
|
|
86
|
+
/fancy-db install
|
|
87
|
+
/fancy-db start | stop
|
|
88
|
+
/fancy-db init [all] # 建 schema + 校验函数 + 权限 + fancy_meta
|
|
89
|
+
/fancy-db init project <名字|UUID>
|
|
90
|
+
/fancy-db list
|
|
91
|
+
/fancy-db remove <名字|UUID> [--purge] # 默认把项目目录移到 .fancy/trash
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`/fancy-db init` 是幂等的;升级插件后重跑它即可更新 schema、`fn::fancy::validate` 和素材库表。
|
|
95
|
+
|
|
96
|
+
### 素材库
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
/fancy-material # 生成全部原子素材类型,按 kind 顺序
|
|
100
|
+
/fancy-material 地名 # 只生成指定类型(其他质量/题材/追加规则仍保留)
|
|
101
|
+
/fancy-material import <文件> # 先校验 JSONL,再追加导入
|
|
102
|
+
/fancy-material init # 创建/补齐素材库 schema、索引、查询脚本
|
|
103
|
+
/fancy-material remove # 删除整个素材库(二次确认)
|
|
104
|
+
/fancy-material prompt # 展示全类型提示词,不发给 LLM
|
|
105
|
+
/fancy-material export # 导出 A/素材库.md
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
素材库是工作区级数据库 `material_library`,所有项目共享。每条素材只有
|
|
109
|
+
`id / kind / name / tags / genres / note / source` 七个字段,是“小积木”而不是人物/金手指大卡。`source=builtin` 的 kind 受内置列表限制,`source=other` 可以使用任意自定义 kind。
|
|
110
|
+
每次运行每个 kind 新增 50 条;每批最多 10 条,插件自动分批完成。无参数且有多份 topic_decision 文件时先弹窗选择使用哪一份。未指定类型时覆盖全部 kind,指定类型时只生成指定 kind;库内不设 50 条上限,运行两次就是每个 kind 100 条。只追加、不覆盖,
|
|
111
|
+
同 `kind + name` 自动去重。表结构和查询说明见
|
|
112
|
+
[`docs/material-library/README.md`](./material-library/README.md)。
|
|
113
|
+
|
|
114
|
+
### 抖音图文采集
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
/fancy-douyin add <主页地址> # 记录一个抖音账号主页
|
|
118
|
+
/fancy-douyin list # 查看已记录账号
|
|
119
|
+
/fancy-douyin remove <序号|地址> # 删除账号记录
|
|
120
|
+
/fancy-douyin # 依次采集所有账号下未处理的图文
|
|
121
|
+
/fancy-douyin help
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
只处理 `/note/` 图文作品,跳过视频。每篇作品截图保存为 JPG,然后交给 LLM
|
|
125
|
+
提取素材,按 material JSONL 写入数据库,`source` 固定为 `other`。已成功入库的
|
|
126
|
+
作品记录在工作区 `/.fancy/douyin/crawled.json`,后续跳过。
|
|
127
|
+
|
|
128
|
+
登录态保存在工作区 `/.fancy/douyin/chrome-profile/`,并镜像 cookies 到
|
|
129
|
+
`login-state.json`。首次需要登录时使用有头 Chrome,登录态有效后后续运行自动
|
|
130
|
+
使用无头 Chrome;无头登录态失效时会切回有头让你手动登录。遇到登录框或安全
|
|
131
|
+
验证时会弹窗,完成后选择「已登陆」。每次运行结束会自动关闭 Chrome。浏览器脚本部署在 `/.fancy-tools/fancy-douyin/`。
|
|
132
|
+
|
|
133
|
+
### 项目
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
/fancy-project list
|
|
137
|
+
/fancy-project find <关键词>
|
|
138
|
+
/fancy-project id <小说名> # 小说名 → UUID
|
|
139
|
+
/fancy-project name <UUID> # UUID → 小说名
|
|
140
|
+
/fancy-project info <名字|UUID>
|
|
141
|
+
/fancy-project use <名字|UUID> # 设为当前项目
|
|
142
|
+
/fancy-project rename <名字|UUID> <新小说名>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
改名只更新注册表和 `story:main.title`;UUID、目录、数据库、项目用户都不变。
|
|
146
|
+
|
|
147
|
+
### 创作
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
/fancy-topic # 在工作区读扫榜结果,生成选题建议
|
|
151
|
+
/fancy-topic setup # 敲定选题 → 创建 projects/<uuid>/ + 项目库 + 用户
|
|
152
|
+
/fancy-build scale|macro|cast|rhythm|outline|write
|
|
153
|
+
/fancy-export
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
项目解析顺序:
|
|
157
|
+
|
|
158
|
+
1. 显式 `--project <名字|UUID>`
|
|
159
|
+
2. 当前 cwd 所在的项目目录
|
|
160
|
+
3. 工作区 `active_project`
|
|
161
|
+
4. 工作区里只有一个项目时自动使用它
|
|
162
|
+
|
|
163
|
+
### 共享分析
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
/fancy-scan → A/扫榜结果/
|
|
167
|
+
/fancy-analyze → 读取 A/参考资料/,产出到 A/对标/
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
多个项目引用同一份工作区产物,不再每个项目重复分析。
|
|
171
|
+
|
|
172
|
+
## 4. 数据库内置校验
|
|
173
|
+
|
|
174
|
+
`fancy-db init` / 每次 `openStoryGraph` 都会生成:
|
|
175
|
+
|
|
176
|
+
- `fn::fancy::validate($scope)`:返回 `{ errors, pending, warnings }`
|
|
177
|
+
- `fn::fancy::version()`:返回 `schema_version` / `validation_version`
|
|
178
|
+
- `fancy_meta:main`:记录当前 schema/validation 版本
|
|
179
|
+
|
|
180
|
+
校验范围:
|
|
181
|
+
|
|
182
|
+
- requirement 生命周期(硬待办与 `complete` 状态冲突)
|
|
183
|
+
- 钩子连续重复
|
|
184
|
+
- 时间线倒退(`time.flashback=true` 例外)
|
|
185
|
+
- 伏笔回收早于埋设
|
|
186
|
+
- 爽点兑现早于铺垫
|
|
187
|
+
- 伏笔/爽点未落地
|
|
188
|
+
- 主线节点/冲突没有章节落点
|
|
189
|
+
- 细纲场景过少、`must_write` 过少
|
|
190
|
+
- 情绪连续平坦、黄金三章钩子不够强
|
|
191
|
+
|
|
192
|
+
写入时的硬规则仍由 `DEFINE EVENT` 触发器拦截;读时校验由 `fn::fancy::validate` 统一返回。任何客户端(DSH、Hermes、CLI)都走同一份校验。
|
|
193
|
+
|
|
194
|
+
## 5. MCP
|
|
195
|
+
|
|
196
|
+
### 5.1 DSH 原生工具
|
|
197
|
+
|
|
198
|
+
DSH 插件加载时会注册原生工具:
|
|
199
|
+
|
|
200
|
+
```text
|
|
201
|
+
fancy_workspace
|
|
202
|
+
fancy_db
|
|
203
|
+
fancy_story_read
|
|
204
|
+
fancy_story_preview
|
|
205
|
+
fancy_story_apply # 自然语言写入:会弹窗要求用户确认
|
|
206
|
+
fancy_story_apply_batch # /fancy-build 阶段批量写入:不逐批弹窗
|
|
207
|
+
fancy_story_export
|
|
208
|
+
fancy_shared_assets
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
自然语言示例:
|
|
212
|
+
|
|
213
|
+
```text
|
|
214
|
+
“我如果把角色 A 和 B 的关系改成对立可行吗?有什么影响?”
|
|
215
|
+
→ fancy_story_read action=impact kind=relation a=A b=B to=对立
|
|
216
|
+
|
|
217
|
+
“把《测试书》改名为《新书名》”
|
|
218
|
+
→ fancy_workspace action=rename project=测试书 title=新书名
|
|
219
|
+
|
|
220
|
+
“把这段关系写入”
|
|
221
|
+
→ fancy_story_apply(先预览,再弹窗确认)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### 5.2 独立 MCP server
|
|
225
|
+
|
|
226
|
+
`plugins/fancy-story-builder/storydb/mcp.js` 是标准 MCP stdio server,可给 Hermes、Claude Desktop、Cursor 等使用:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
FANCY_HOME=/path/to/A node plugins/fancy-story-builder/storydb/mcp.js
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
DSH 里如果要走 MCP 而不是原生工具,在 profile 的 `cordis.patch.yml` 增加:
|
|
233
|
+
|
|
234
|
+
```yaml
|
|
235
|
+
- insert:
|
|
236
|
+
- id: mcp-fancy
|
|
237
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
238
|
+
config:
|
|
239
|
+
serverName: fancy
|
|
240
|
+
transport: stdio
|
|
241
|
+
command: node
|
|
242
|
+
args: ['/absolute/path/to/fancy-webnovel/plugins/fancy-story-builder/storydb/mcp.js']
|
|
243
|
+
env:
|
|
244
|
+
FANCY_HOME: '/absolute/path/to/A'
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
工具名会变成 `mcp__fancy__story_status`、`mcp__fancy__story_apply` 等。
|
|
248
|
+
|
|
249
|
+
### 5.3 写操作二次确认
|
|
250
|
+
|
|
251
|
+
自然语言写操作分两步:
|
|
252
|
+
|
|
253
|
+
1. `story_preview` / `fancy_story_preview`:只做形状校验,返回 `preview` 和签名过的 `confirmation_token`。
|
|
254
|
+
2. 用户确认后,`story_apply` / `fancy_story_apply` 带同一个 payload + token 才提交。
|
|
255
|
+
|
|
256
|
+
- DSH 原生 `fancy_story_apply` 额外调用 `ctx.userQuestions` 弹窗确认。
|
|
257
|
+
- `/fancy-build` 阶段是用户显式发起的批量创作,阶段提示词使用 `fancy_story_apply_batch`,不会逐批弹窗;自然语言改数据仍走 `fancy_story_apply`。
|
|
258
|
+
- MCP 客户端没有 UI 时,工具描述要求 agent 先把预览给用户看;没有 token 的 `story_apply` 会返回 `CONFIRMATION_REQUIRED`,不会写入。
|
|
259
|
+
- 只读分析(`story_impact`、`story_validate`、`story_status` 等)直接执行。
|
|
260
|
+
|
|
261
|
+
## 6. 环境变量
|
|
262
|
+
|
|
263
|
+
| 变量 | 作用 |
|
|
264
|
+
| --- | --- |
|
|
265
|
+
| `FANCY_HOME` | 指定工作区;优先于 cwd/全局指针 |
|
|
266
|
+
| `FANCY_SURREAL_BIN` | 指定 `surreal` 二进制路径 |
|
|
267
|
+
| `FANCY_STORY_GRAPH=0` | 仅保留给历史回归测试;新项目不要用 |
|
|
268
|
+
|
|
269
|
+
工作区元数据里的时间统一使用**本机时区的 ISO-8601**,并带时区偏移,例如 `2026-11-11T17:16:08.219+08:00`。
|
|
270
|
+
|
|
271
|
+
全局工作区注册表:`~/.config/fancy-webnovel/workspaces.json`(首次 bootstrap 后追加/更新,记录本机所有工作区;`/fancy-bootstrap list` 查看)。旧版单指针 `workspace.json` 仍会同步写入,供旧版本兼容。
|
|
272
|
+
|
|
273
|
+
- 斜杠命令只从 **cwd / FANCY_HOME** 解析工作区,不会因为本机有另一个工作区就自动串过去。
|
|
274
|
+
- MCP / 原生工具在 cwd 找不到工作区时,才使用全局注册表里最近使用的可用工作区。
|
|
275
|
+
|
|
276
|
+
## 7. 旧项目
|
|
277
|
+
|
|
278
|
+
新分支**不迁移**旧项目:
|
|
279
|
+
|
|
280
|
+
- 不读取旧项目根目录的 `.fancy-storydb`
|
|
281
|
+
- 不读取旧项目根目录的 `_novel_topic.json` 作为项目元数据
|
|
282
|
+
- 旧版 `FANCY_STORY_GRAPH=0` 文件管线只作为历史测试兼容,不属于新架构
|
|
283
|
+
|
|
284
|
+
旧项目如需保留,请在旧分支继续使用;新项目请从 `/fancy-bootstrap` + `/fancy-topic setup` 开始。
|
package/index.js
CHANGED
|
@@ -5,16 +5,16 @@ import { apply as applyTopic } from './plugins/fancy-topic/index.js';
|
|
|
5
5
|
import { apply as applyAnalyze } from './plugins/fancy-analyze/index.js';
|
|
6
6
|
import { apply as applyStoryBuilder } from './plugins/fancy-story-builder/index.js';
|
|
7
7
|
import { apply as applyImport } from './plugins/fancy-import/index.js';
|
|
8
|
-
import {
|
|
8
|
+
import { apply as applyExport } from './plugins/fancy-export/index.js';
|
|
9
|
+
import { apply as applyDb } from './plugins/fancy-db/index.js';
|
|
10
|
+
import { apply as applyProject } from './plugins/fancy-project/index.js';
|
|
11
|
+
import { apply as applyMaterial } from './plugins/fancy-material/index.js';
|
|
12
|
+
import { apply as applyDouyin } from './plugins/fancy-douyin/index.js';
|
|
9
13
|
|
|
10
|
-
export const
|
|
14
|
+
export const name = 'fancy-webnovel';
|
|
15
|
+
export const inject = ['commands', 'userQuestions', 'tools'];
|
|
11
16
|
|
|
12
17
|
export async function apply(ctx) {
|
|
13
|
-
// bundle 级提示:沙箱下 rm 会挂死(见 lib/safe-rm.js)。
|
|
14
|
-
// 放在这里而不是各子插件里,是因为每个子插件都可能让模型删临时文件,
|
|
15
|
-
// 逐个加必然漏 —— 一次注册对整个 bundle 的所有会话生效。
|
|
16
|
-
registerSafeRmPrompt(ctx);
|
|
17
|
-
|
|
18
18
|
await applyBootstrap(ctx);
|
|
19
19
|
await applyScan(ctx);
|
|
20
20
|
await applyTopic(ctx);
|
|
@@ -22,4 +22,9 @@ export async function apply(ctx) {
|
|
|
22
22
|
await applyStoryBuilder(ctx);
|
|
23
23
|
// 导入放在最后:它要用 story-builder 的模板与校验器
|
|
24
24
|
await applyImport(ctx);
|
|
25
|
+
await applyExport(ctx);
|
|
26
|
+
await applyDb(ctx);
|
|
27
|
+
await applyProject(ctx);
|
|
28
|
+
await applyMaterial(ctx);
|
|
29
|
+
await applyDouyin(ctx);
|
|
25
30
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* atomic-write.js —— 同目录临时文件 + rename 的原子写
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么不用 `writeFileSync(path)`
|
|
5
|
+
*
|
|
6
|
+
* 直接覆盖写,进程写到一半被杀(OOM / 磁盘满 / 用户 Ctrl-C)会把文件截断。
|
|
7
|
+
* 对 `.fancy-deployed` 来说就是项目状态没了,对 `_novel_topic.json` 来说
|
|
8
|
+
* 就是已敲定的选题没了。rename 在同一文件系统内是原子的:要么旧内容完整,
|
|
9
|
+
* 要么新内容完整,不会出现半份文件。
|
|
10
|
+
*
|
|
11
|
+
* ## 为什么临时文件名要随机 + `wx`
|
|
12
|
+
*
|
|
13
|
+
* 旧实现用 `${file}.tmp-${process.pid}`,名字可预测。项目根是用户可写的,
|
|
14
|
+
* 攻击者可以提前放一个同名符号链接,`writeFileSync` 会跟着链接写进
|
|
15
|
+
* 项目外的文件。现在用随机后缀,并以 `wx`(O_CREAT|O_EXCL)打开:
|
|
16
|
+
* 已存在就失败,不会跟随符号链接写;rename 覆盖目标时也只会替换目录项,
|
|
17
|
+
* 不会写到符号链接指向的地方。
|
|
18
|
+
*
|
|
19
|
+
* ## 权限
|
|
20
|
+
*
|
|
21
|
+
* `.fancy-deployed` 里有 llm_wiki_token,默认 0600;普通 JSON 用 0644。
|
|
22
|
+
* 调用方按内容决定 mode。
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
chmodSync,
|
|
26
|
+
closeSync,
|
|
27
|
+
fsyncSync,
|
|
28
|
+
lstatSync,
|
|
29
|
+
openSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
unlinkSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from 'fs';
|
|
34
|
+
import { basename, dirname, join } from 'path';
|
|
35
|
+
import { randomBytes } from 'crypto';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 原子写文本文件。
|
|
39
|
+
*
|
|
40
|
+
* @param {string} file 目标文件(必须已存在父目录)
|
|
41
|
+
* @param {string|Buffer} content 内容
|
|
42
|
+
* @param {{mode?: number}} [opts]
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
export function atomicWriteFile(file, content, opts = {}) {
|
|
46
|
+
const mode = typeof opts.mode === 'number' ? opts.mode : 0o644;
|
|
47
|
+
const dir = dirname(file);
|
|
48
|
+
const tmp = join(
|
|
49
|
+
dir,
|
|
50
|
+
`.${basename(file)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
let fd = null;
|
|
54
|
+
try {
|
|
55
|
+
// wx:文件已存在(含符号链接)就失败,绝不跟随链接写
|
|
56
|
+
fd = openSync(tmp, 'wx', mode);
|
|
57
|
+
writeFileSync(fd, content, 'utf-8');
|
|
58
|
+
// 落盘后再 rename,避免 rename 成功、内容还在页缓存时断电
|
|
59
|
+
try { fsyncSync(fd); } catch { /* 某些文件系统不支持 fsync,不阻塞主流程 */ }
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
fd = null;
|
|
62
|
+
try {
|
|
63
|
+
renameSync(tmp, file);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// Windows 不允许 rename 覆盖只读文件。工具/清单以 0444/0400 部署,
|
|
66
|
+
// 下次更新或同日重跑时必须先临时恢复写权限;rename 成功后文件仍是
|
|
67
|
+
// 临时文件的只读 mode。非权限错误照常抛出。
|
|
68
|
+
if (e.code !== 'EPERM' && e.code !== 'EACCES') throw e;
|
|
69
|
+
// lstat:目标若是符号链接,绝不能 chmod(会改到链接指向的文件)。
|
|
70
|
+
// 这种情况直接重试 rename;替换链接本身是安全的。
|
|
71
|
+
let oldMode = null;
|
|
72
|
+
try {
|
|
73
|
+
const st = lstatSync(file);
|
|
74
|
+
if (!st.isSymbolicLink()) oldMode = st.mode & 0o777;
|
|
75
|
+
} catch { /* 目标不存在 */ }
|
|
76
|
+
if (oldMode === null) {
|
|
77
|
+
renameSync(tmp, file);
|
|
78
|
+
} else {
|
|
79
|
+
try { chmodSync(file, 0o644); } catch { /* 由下面的 rename 报错 */ }
|
|
80
|
+
try {
|
|
81
|
+
renameSync(tmp, file);
|
|
82
|
+
} catch (e2) {
|
|
83
|
+
try { chmodSync(file, oldMode); } catch { /* ignore */ }
|
|
84
|
+
throw e2;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
if (fd !== null) {
|
|
90
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
// 临时文件按目标 mode 创建;Windows 上 0444 只读文件不能直接删除,
|
|
93
|
+
// 先恢复写权限再清理,避免失败路径留下只读垃圾。
|
|
94
|
+
try { chmodSync(tmp, 0o644); } catch { /* 文件可能没创建成功 */ }
|
|
95
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 原子写 JSON(2 空格缩进,不追加尾随换行,保持旧文件格式)。
|
|
102
|
+
*
|
|
103
|
+
* @param {string} file
|
|
104
|
+
* @param {unknown} data
|
|
105
|
+
* @param {{mode?: number}} [opts]
|
|
106
|
+
*/
|
|
107
|
+
export function atomicWriteJSON(file, data, opts = {}) {
|
|
108
|
+
atomicWriteFile(file, JSON.stringify(data, null, 2), opts);
|
|
109
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chapter-order.js —— 按(书名, 章号)排序章节列表
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要
|
|
5
|
+
*
|
|
6
|
+
* 几处"章节列表"的顺序全是随机的:
|
|
7
|
+
*
|
|
8
|
+
* · llm_wiki 的 querystatus 返回的 missingFiles —— 服务端怎么存的就怎么给;
|
|
9
|
+
* · /fancy-analyze status 的"缺失检测"和"列表未找到" —— 本地算的,
|
|
10
|
+
* 遍历的是 readdirSync 的返回顺序(ext4 的目录项哈希序,不是字典序);
|
|
11
|
+
* · 写进 .retry 的 items —— 继承上面的顺序。
|
|
12
|
+
*
|
|
13
|
+
* 结果就是用户看到
|
|
14
|
+
*
|
|
15
|
+
* - 全球高武/第7章-武科五大关.txt
|
|
16
|
+
* - 全球高武/第4章-人穷万事休.txt
|
|
17
|
+
* - 全球高武/第8章-心好痛.txt
|
|
18
|
+
*
|
|
19
|
+
* 这种顺序。对"还剩哪些章没拆"这种问题,人是按章号顺序扫的,
|
|
20
|
+
* 乱序反而让人怀疑是不是漏了、重复了。
|
|
21
|
+
*
|
|
22
|
+
* ## 排序口径
|
|
23
|
+
*
|
|
24
|
+
* 1. 书名自然序(`localeCompare(..., {numeric:true})`,"第2章" < "第10章");
|
|
25
|
+
* 2. 同书名内按章号数字升序;
|
|
26
|
+
* 3. 解析不出章号的排在有章号的**后面**;
|
|
27
|
+
* 4. 最后按原字符串自然序兜底,保证排序稳定且确定。
|
|
28
|
+
*
|
|
29
|
+
* 章号只认阿拉伯数字(`第7章`)。中文数字(`第七章`)在 analyze 侧
|
|
30
|
+
* 不会出现 —— split_source.js 落盘时已经把章号统一成阿拉伯数字了。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** 章号:第 N 章(N 是阿拉伯数字,允许中间有空格) */
|
|
34
|
+
const CHAPTER_NUM_RE = /第\s*(\d+)\s*章/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 从任意文本(或带 chapter 字段的对象)里取章号。
|
|
38
|
+
*
|
|
39
|
+
* 对象形态是 `.retry` 里的 { bookName, chapter }。
|
|
40
|
+
*
|
|
41
|
+
* @param {string|{chapter?: string}} text
|
|
42
|
+
* @returns {number|null} 取不到返回 null
|
|
43
|
+
*/
|
|
44
|
+
export function chapterNumber(text) {
|
|
45
|
+
const raw = (text && typeof text === 'object')
|
|
46
|
+
? (text.chapter ?? '')
|
|
47
|
+
: text;
|
|
48
|
+
const m = CHAPTER_NUM_RE.exec(String(raw || ''));
|
|
49
|
+
return m ? Number(m[1]) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 取"第一个章号之前"的部分作为书名。
|
|
54
|
+
*
|
|
55
|
+
* "全球高武/第7章-武科五大关.txt" → "全球高武"
|
|
56
|
+
* "全球高武第7章的ingest有缺失" → "全球高武"
|
|
57
|
+
*
|
|
58
|
+
* @param {string} text
|
|
59
|
+
* @returns {string} 取不到章号时返回整个字符串(当兜底书名用)
|
|
60
|
+
*/
|
|
61
|
+
export function bookBeforeChapter(text) {
|
|
62
|
+
const s = String(text || '');
|
|
63
|
+
const m = CHAPTER_NUM_RE.exec(s);
|
|
64
|
+
// 取不到章号时不能返回空串 —— 空串比任何非空串都小,会让它排到
|
|
65
|
+
// 所有有章号的项前面,跟"解析不出的排后面"这条规则正好相反。
|
|
66
|
+
if (!m) return s.trim();
|
|
67
|
+
return s.slice(0, m.index).replace(/[/\\]+$/, '').trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 按(书名, 章号)排序。
|
|
72
|
+
*
|
|
73
|
+
* 不改原数组,返回新数组。
|
|
74
|
+
*
|
|
75
|
+
* @param {Array} items
|
|
76
|
+
* @param {(item:any)=>string} [getBook] - 取书名的函数,默认用"章号之前的部分"
|
|
77
|
+
* @returns {Array} 排序后的新数组
|
|
78
|
+
*/
|
|
79
|
+
export function sortByChapter(items, getBook = bookBeforeChapter) {
|
|
80
|
+
if (!Array.isArray(items)) return [];
|
|
81
|
+
return [...items].sort((a, b) => {
|
|
82
|
+
const numA = chapterNumber(a);
|
|
83
|
+
const numB = chapterNumber(b);
|
|
84
|
+
|
|
85
|
+
// 先分组:有章号的在前,解析不出的统一排到后面。
|
|
86
|
+
// 这一步必须排在书名比较之前 —— 否则取不到章号的项用的是兜底书名,
|
|
87
|
+
// 跟别的书一比就会插到中间去。
|
|
88
|
+
if (numA !== null && numB === null) return -1;
|
|
89
|
+
if (numA === null && numB !== null) return 1;
|
|
90
|
+
|
|
91
|
+
const bookA = String(getBook(a) || '');
|
|
92
|
+
const bookB = String(getBook(b) || '');
|
|
93
|
+
const byBook = bookA.localeCompare(bookB, undefined, { numeric: true });
|
|
94
|
+
if (byBook !== 0) return byBook;
|
|
95
|
+
|
|
96
|
+
if (numA !== null && numB !== null) return numA - numB;
|
|
97
|
+
|
|
98
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-phase confirmation tokens for natural-language writes.
|
|
3
|
+
*
|
|
4
|
+
* The server cannot see the chat window, so it enforces a protocol-level
|
|
5
|
+
* two-step flow: `story_preview` returns a signed token over the exact payload;
|
|
6
|
+
* `story_apply` refuses to write without it. The tool description instructs the
|
|
7
|
+
* agent to show the preview to the user and only pass the token after an
|
|
8
|
+
* explicit confirmation. DSH's native adapter adds a real UI confirmation on
|
|
9
|
+
* top of this.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import { readSecrets, readWorkspace } from './workspace.js';
|
|
14
|
+
|
|
15
|
+
const TTL_MS = 10 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function secretFor(workspace) {
|
|
18
|
+
try {
|
|
19
|
+
const secrets = readSecrets(workspace);
|
|
20
|
+
if (secrets.confirmation_secret) return secrets.confirmation_secret;
|
|
21
|
+
// Fall back to the SurrealDB root password: it is already workspace-local
|
|
22
|
+
// and 0600. Never expose it in the token itself.
|
|
23
|
+
if (secrets.surreal?.password) return 'surreal:' + secrets.surreal.password;
|
|
24
|
+
} catch { /* ignore */ }
|
|
25
|
+
return 'fancy-confirmation-fallback';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function canonical(value) {
|
|
29
|
+
if (value === null || value === undefined) return 'null';
|
|
30
|
+
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
31
|
+
if (typeof value === 'object') {
|
|
32
|
+
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sign(secret, body) {
|
|
38
|
+
return crypto.createHmac('sha256', secret).update(body).digest('base64url');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createConfirmation(workspace, project, payload) {
|
|
42
|
+
const body = Buffer.from(JSON.stringify({
|
|
43
|
+
v: 1,
|
|
44
|
+
workspace: workspace,
|
|
45
|
+
project: project.project_id,
|
|
46
|
+
exp: Date.now() + TTL_MS,
|
|
47
|
+
payload,
|
|
48
|
+
}), 'utf8').toString('base64url');
|
|
49
|
+
const token = body + '.' + sign(secretFor(workspace), body);
|
|
50
|
+
return {
|
|
51
|
+
confirmation_token: token,
|
|
52
|
+
expires_at: new Date(Date.now() + TTL_MS).toISOString(),
|
|
53
|
+
ttl_seconds: Math.floor(TTL_MS / 1000),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function verifyConfirmation(workspace, project, token, payload) {
|
|
58
|
+
const text = String(token || '');
|
|
59
|
+
const idx = text.lastIndexOf('.');
|
|
60
|
+
if (idx <= 0) return { ok: false, reason: 'malformed' };
|
|
61
|
+
const body = text.slice(0, idx);
|
|
62
|
+
const signature = text.slice(idx + 1);
|
|
63
|
+
const expected = sign(secretFor(workspace), body);
|
|
64
|
+
if (signature.length !== expected.length
|
|
65
|
+
|| !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
|
66
|
+
return { ok: false, reason: 'signature' };
|
|
67
|
+
}
|
|
68
|
+
let decoded;
|
|
69
|
+
try {
|
|
70
|
+
decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return { ok: false, reason: 'body' };
|
|
73
|
+
}
|
|
74
|
+
if (decoded.v !== 1) return { ok: false, reason: 'version' };
|
|
75
|
+
if (decoded.workspace !== workspace) return { ok: false, reason: 'workspace' };
|
|
76
|
+
if (decoded.project !== project.project_id) return { ok: false, reason: 'project' };
|
|
77
|
+
if (Number(decoded.exp) < Date.now()) return { ok: false, reason: 'expired' };
|
|
78
|
+
if (canonical(decoded.payload) !== canonical(payload)) return { ok: false, reason: 'payload' };
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|