lyjsyyds/dsh-novel-studio ↗★ 0

dsh-novel-studio-lyjs

Novel Studio — a dedicated writing partition for DSH: characters, worldbuilding, outline, panels, chapters. 专为小说创作者设计,适合在DSH中进行长篇叙事和设定管理。

패키지
dsh-novel-studio-lyjs
호환성
미검증
버전
0.2.0
라이선스
MIT
최근 업데이트
2026. 9. 30.

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:lyjsyyds/dsh-novel-studio

Novel Studio

一个独立于任务与聊天之外的小说分区,作为 DeepSeek Harness 插件运行。

侧栏出现一本书的图标;点开后是两块:左边书架(每部小说一个条目,标题旁一个箭头可以把它折成 一条竖边、把宽度全让给正文,选择记在浏览器里,没记录时默认收起),右边当前作品的十一个分区 (总览 · 人物 · 世界观 · 大纲 · 面板 · 正文 · 素材 · 草稿)加上 检索 · 进度 · 导入 · 设置 与 关系图 · 校验 · AI · 导出 八个工具标签。

检索在一个地方找遍整本书:正文(带行号)、每一条记录、每个字段、素材文件名、原始 YAML。 搜到的条目点一下就跳到它所属的分区并高亮,所以「这个名字到底在哪写过」不用挨个标签翻。

进度把写作量画出来:总字数、今日与本周、连续写作天数、30 天历史曲线,以及设了目标之后的 「还差多少 / 每天要写多少 / 按当前速度哪天写完」。它按每日净产出记账(progress.yaml), 所以删掉一段正文是负的,不是「今天写了 0 字」。

关系图把人物、势力、地点、物品画成一张网:名字旁边的字段(势力的 leader、地点的 region、 物品的 owner)会自动连边,手写的边存在 relationships.yaml 里。只看人物时,各人所属的势力仍会 作为灰色上下文节点画出来,所以不会看到一堆孤立的点。

校验跑一遍书的内部一致性:悬空的边、指向未知条目的 [[链接]]、重号或断号的章节、还没回收的 伏笔、空分区等等,分「错误 / 提醒 / 提示」三档列出来。它只读,不会改任何文件。

导出把整本书渲染成一份可读的稿子:Markdown 手稿、纯文本、可打印的单页 HTML,或者一份完整的 JSON 备份(含所有分区数据与素材正文,且不含本地路径)。可以在面板里预览、复制、下载,也能落盘到该书的 publish/ 目录并留下 manifest.yaml 记录;扩展可以再添自己的格式。

导入与恢复是导出的回程:一份 JSON 备份可以整体恢复(逐分区报告会写什么、跳过什么、删什么) 或恢复成一本书;一段粘贴进来的 Markdown 手稿可以按它自己的 # 第一章 切章,追加或整本替换; 书里 materials/、publish/、import/ 下已有的 .md / .txt / .json 也能直接读进来。默认只预览, 确认之后才落盘,替换整本书还要再确认一次。

草稿箱在改章节之前先留快照,改坏了能退回去;从草稿恢复时会先自动存一份「恢复前的样子」, 所以退回一步本身也是可退的。

它同时是一块留给 AI 继续加东西的地盘:新分区、新路由、新工具都是 lib/extensions/ 下的一个 .js 文件,写完下一个请求就生效。见 EXTENSIONS.md。

为什么是插件而不是一堆文件夹

小说数据本身就是纯文件——每部作品在书库根目录下拥有一个完全独立的目录,结构化记录用 YAML, 正文用 Markdown。你可以直接打开文件夹阅读、手改、丢进 git,插件只是一层透镜。 多部作品之间不共享任何东西:人物、世界观、大纲、面板、正文互不干扰。

~/.dsh/novels//
  book.yaml                作品元信息
  settings.yaml
  characters/.yaml     人物
  relationships.yaml       关系
  world/                   世界观:rules / locations / factions / timeline / items / races / cultures / economy / glossary
  outline/                 大纲:tree / threads / scenes / foreshadowing / beats / hooks
  panels/                  面板:status / skills / equipment / tasks / reputation + custom/
  chapters/.md         正文(YAML front matter + Markdown 正文)
  meta/                    章节元信息
  drafts/                  草稿
  materials/               素材
  publish/                 发布产物

书库根目录默认 ~/.dsh/novels。位置按四层决定,先匹配先生效:显式参数 → 环境变量 DSH_NOVEL_ROOT → 面板上保存的位置(写到 ~/.dsh/novel-studio.yaml 的 root)→ 默认位置。 环境变量故意压过面板设置:部署或测试拿 DSH_NOVEL_ROOT 钉住书库时,面板就改不动它 (GET /root 回 locked: true),否则一次面板操作就能把测试的书写进真实书库。

面板书架底部写着当前位置、来源和一个「更改」:点「浏览」会打开系统自己的文件夹选择框(和「打开 工作空间」用的是同一个 Host 对话框,任意磁盘都能去),选好就填进输入框,再按「切换」才真正生效 ——浏览本身什么都不改。没有那个服务时(或直接手填路径也可以)会退回到面板内置的只读文件夹浏览器: 从「这台电脑」逐级点下去(只列文件夹,另有「上一级 / 磁盘 / 主目录」三个快捷跳转),按 「用这个文件夹」填进输入框。也可以勾上「把现有的书 一起搬过去」,确认后下一个请求就生效,不用重启——lib/api.js 每个请求都重新解析一次位置。 搬运用逐本 rename(跨盘退回拷贝 + 删除),撞名的书留在原处并在结果里记 target-exists, 永不覆盖。 新位置必须是绝对路径,且不能是当前位置的上层或子目录:两个书库互相套住,下次扫描就会把同一批 书数两遍。

路由作用
GET /root书库在哪、为什么在那(source)、是否被环境变量钉住、配置文件路径
POST /root{ root, move? } 换位置(move: true 连书一起搬);{ reset: true } 交还给默认
GET /folders?path= 逐级列文件夹:不给 path 列这台电脑的盘符,给了就列该目录下的子文件夹(parent/home 供跳转)。只在这台 Host 没有原生文件夹对话框时给面板「浏览」当回落(有对话框时走 uiWorkspace.pickDirectory());只读,从不写盘

结构

文件半边作用
lib/index.jshost稳定载体:注册前缀路由 + agent 工具,递归按 mtime 热重载 lib/ 下所有 .js
lib/api.jshostHTTP 路由分发(/ping、/bootstrap、/schema、/root、/folders、/library[...]、/ext/...)
lib/ops.jshost统一操作层:invoke(operation, payload),HTTP / 工具 / 扩展共用,从不抛异常
lib/library.jshost数据层:书库扫描、建书脚手架、原子写、计数、增删改查
lib/schema.jshost声明式分区树(六种 kind)+ 扩展发现与合并
lib/records.jshost通用条目读写(records / doc / chapters / files / raw)
lib/graph.jshost关系图:节点、边、字段自动连边、图内异常(只读)
lib/edges.jshost关系图的写方:增删改一条关系(手写行 or 记录字段)
lib/validate.jshost一致性校验:13 条内置规则 + 扩展规则(只读)
lib/export.jshost导出与发布:四种内置格式 + 扩展格式、publish/ 产物清单
lib/search.jshost全库检索:正文(带行号)、记录字段、文件名、原始 YAML,同一个字段只报一次
lib/chapters.jshost章节的读序与改名:排序计划、断号 / 重号 / 未编号诊断、重排、重编号,以及草稿箱
lib/progress.jshost写作进度:progress.yaml 里的每日净产出、目标、节奏推算与 30 天历史
lib/import.jshost导入与恢复:JSON 备份回灌、Markdown 切章、书内文件直读,默认 dry-run
lib/usage.jshost每本书一本 token 账本(usage.yaml):拼写归一化、累加、归零
lib/extract.jshost写回正文后的补材料:把一段正文里新出现的人 / 地 / 物列成清单,交回前按 schema 校验,落库只填空字段
lib/prompt.jshost纯函数的提示词装配:五个 AI 任务、设定渲染、预算截断、buildPrompt
lib/ai.jshostAI 联动:取书里的真实数据 → 拼提示词 → 认路由 → 调 llm.stream 流式回吐
lib/tools.jshost模型可见工具的定义(含扩展贡献的工具)
lib/extensions/host扩展目录;_ 开头的文件保留但不加载
lib/client.jsbrowser唯一浏览器入口:注册 sidebar.panellist 与 main 两个 seam
EXTENSIONS.md—扩展开发指南
tests/*.mjs—冒烟测试,独立于 DSH 运行

为什么要 lib/index.js 这一层。 DSH 在进程启动时加载一次 host 半边,直接写在里面的代码改完必须 重启 App 才生效。所以除它以外的一切都放进会被重新 import 的模块里——index.js 每次请求递归扫描 lib/**/*.js 算指纹,指纹变了就换一个 ?v= 重新加载,下一个请求就是新代码。指纹是递归的, 所以新增一个扩展文件本身就会触发重载。只有 index.js 自身改动才需要重启。

浏览器半边由模块加载器在页面加载时取用,改完刷新页面即可。

给 AI 的接口

插件向 DSH 的 tools 服务注册十六个工具(经 ctx.inject(['tools'], …),没有该服务时面板照常工作):

工具作用
novel_guide读插件手册:overview / data / extend
novel_library建书、列书、改元信息、删书
novel_records读写真个分区里的条目;action: 'schema' 先看分区在哪
novel_graph取关系网;kinds: 'list' 看有哪些节点类型,传数组则只取那几类
novel_link增删改一条关系;派生边改的是记录字段,各分区同步
novel_validate跑一致性校验,返回错误 / 提醒 / 提示三档清单(只读)
novel_export渲染整本书并交回文本(md / txt / html / json),不写盘
novel_publish落盘到 publish/:formats / list / write / delete
novel_extension装扩展:scaffold 出模板 → write 写入 lib/extensions/
novel_ai在聊天里跑这本书的 AI 任务:list 看菜单 / context 预览会读到什么 / run 真跑一轮(同样支持 pick 与 history)
novel_search在整本书里找词:正文(含行号)、记录字段、文件名、原始 YAML,返回命中位置与片段
novel_chapters读序计划(编号风格、断号 / 重号 / 未编号)、reorder 重排、renumber 重编号(dryRun 先看)
novel_drafts草稿箱:list / read / save(改章节前的快照)/ restore(冲突要 force)/ delete
novel_progress进度:get 拿总字数、今日 / 本周、连续天数、30 天历史与节奏;set 定目标字数与截止日
novel_import导入:list 书内可导入的文件 / backup 读 JSON 备份(describe 先看内容)/ markdown 按标题切章
novel_extract读一段正文并列出新条目:recognize 只给清单(不写盘)/ apply 把选中的条目补进各分区,只填空字段

novel_extension 的 write 会真的 import 一次做校验——语法错、顶层抛错、导出形状不对,都会把文件 回滚删除并说明原因。工具集合在指纹变化时重新同步,所以扩展新增的工具同样不需要重启。

AI 联动

面板的「AI」标签把这本书的真实数据喂给模型,做本章续写、新章节续写、润色、大纲推演、一致性审稿。 取的是当前书的关系图、人物、世界规则、术语、故事线、情节节拍、章节索引和校验问题,不是让模型凭记忆猜。

跑一轮之前可以勾选要喂进去的条目:面板会按分区列出这本书已有的内容(人物、地点、势力、物品、 种族、文化、规则、术语、经济、年表事件、卷章结构、线索、伏笔、节拍、钩子、场景、面板各项……), 默认全选,取消勾选的条目不会进入提示词。分区列表由 lib/schema.js 推导,所以之后新增的分区 (包括扩展贡献的)会自动出现在勾选面板里。

路由作用
GET /library/:book/ai/tasks五个任务的可序列化菜单(key / zh / needs / hint)
GET /library/:book/ai/context「AI 会读到什么」的预览计数 + 勾选目录(?pick= 反映当前选择,不含正文)
POST /library/:book/ai跑一个任务,text/event-stream 逐块回吐(body 里的 pick 决定读哪些条目)

pick 的形状是 { 分区键: [条目 id, …] }:数组表示只留这些条目,空数组或 false 表示整段不要, true 表示整段都要,没提到的分区按默认(全要)处理。分区键就是分区路径(characters、 world/locations、world/timeline、outline/beats…)。形状不对会被在开流之前用 400 bad-pick 答掉,指向旧目录的未知分区键会被忽略。

多轮对话:body 里还可以带 history: [{ role: 'user' | 'assistant', content: '…' }]—— 上几轮的问答会排在这次提示词之前一起交给模型,所以「续写」能顺着上一轮接着写。只保留最近 12 轮、每轮最多 4000 字符(超出截断并加 …);角色只能是 user 或 assistant。同样在 开流之前校验:形状不对用 400 bad-history 答掉。面板的 AI 标签自己维护这份对话(每本书各自 一本账,换书即新账,可一键清空),HTTP 调用方自行传 history 即可。

续写分成两条:continue(本章续写)从当前章节的正文末尾接着往下写;continue-new(新章节续写) 从这一章的结尾往后另起一章——不写章节标题、不复述上一章,篇幅也给得更宽(2400 token 对 1600)。 选中哪一条,下面「存到」就跟着换:本章续写默认追加进这一章,新章节续写默认写成新章节,并 按这本书现有的编号猜一个下一章名(第三章-… 往后数)填在可编辑的输入框里。章节文件由宿主的建章 路由落盘,重名自动加后缀,所以猜错也不会覆盖任何已有章节。

答案是你可以改的:右侧答案框是文本框,不是打印件——名字写错、句子别扭,直接在上面改。写回章节、 存成素材、写成新章节、「识别材料」读的都是你改过的这一版(走的是同一份文字)。答案框有高度上限 并可上下拖动调整,所以一致性审稿那种长回答也不会把下面的按钮顶出屏幕。

每本书自己的模型:AI 默认跟着 agentDefaultModel 当前选的 provider / model 走,但每本书可以在 面板「AI」标签顶部换成自己的,存进 book.yaml 的 aiModel: { provider, model, reasoningEffort? }。 书与书之间、书与全局选择之间互不影响:lib/ai.js 的 bookSelection(book, default) 只把这本书的 选择并到默认之上,且 reasoningEffort 只在路由完全相同时才继承(同 provider 同 model)—— effort id 属于某个具体模型,搬到别的模型上只是猜。记录只填一半(有 provider 没 model)按「没选」 处理,于是这本书照旧跑默认。

思考强度(reasoningEffort):模型能跑的档位由部署自己的模型登记表决定(本机 deepseek-* 是 off / low / high / max,模型自带默认 high),面板在模型选择旁列出「跟随模型默认」+ 这些档位。 选一档就照上面那样写进 book.yaml;选「跟随模型默认」就是不带这个字段、交回模型自己——这也是清除 的办法。档位来自 GET /library/:book/efforts,面板既不问也不猜;问不到(模型不可描述、旧宿主没有 这个接口)就不显示这一栏,而不是拿一个可能被 provider 拒掉的 id 去试。日常续写调低一档,省时间也省 token。

路由作用
GET /library/:book/model这本书自己的路由 + 它此刻会继承的默认 + 本机可用的模型目录
POST /library/:book/model{ provider, model, reasoningEffort? } 保存;{ clear: true } 交还给默认
GET /library/:book/efforts这个模型能跑的思考强度档位 + 它自己的默认档(带 ?provider=&model= 可查正在挑选、尚未保存的模型)

篇幅与用量:body 里可以带 words(1–20000 的整数),提示词会追加一段「### 篇幅要求」,这一轮 的 maxTokens 也随之抬到 min(8000, max(任务默认, N × 2));不是整数、或超出范围,用 400 missing-argument 答掉(空着就是不要求)。start 事件回传这一轮的 words,done 事件回传 usage。开着流花的钱也算钱:每本书一本 usage.yaml,只要这一轮报了 token 就记一笔,失败收尾 的那轮同样记;拼写按 provider 常见的几种归一化(input_tokens / promptTokens …),没有 total 就自己加。面板上分两层看:正文下面那行是本轮(跑完就刷新,下一轮开始时清零),累计藏在一个 可折叠的子面板里(「累计用量 Σ …」,展开时才重新读一遍账本,里面还有轮次、起始 / 最近时间与一键 归零)——写作时盯的是一轮花了多少,不是这本书一辈子的总数。

费用面板里的归属:usage.yaml 是面板自己的账,跟部署里的计价插件(dsh-cost-meter)是两本账。 插件挂在全局的 llm/stream 上,把每一次调用按 options.sessionId 分进「一个会话一行」,没有 sessionId 的调用只进日 / 月合计、没有会话行——小说区的调用是手搓的(没有 agent loop 认领),所以 过去只能趴在「无归属」里,按会话看费用时根本看不见。lib/ai.js 的 attributionId(book, bookDir) 现在给这类调用一个自己的会话 id(novel-studio:),补材料那次调用同样记在所属书的名下。 这个 id 会被 provider 直接写成 HTTP 头 x-deepseek-harness-session-id,而头值只能是 Latin-1 —— 非 ASCII 的书名(例如中文)会让每一次调用都以 transport failed 失败,所以字符集容不下的书名 统一落到共享的 novel-studio 一行(ASCII 书名自成一行)。每本书一行看起来更细,但费用面板对没有 标题的会话只显示 id 的前 14 个字符,novel-studio: 前缀就占了 13 个,所以强行编码成乱码反而更读不懂。

路由作用
GET /library/:book/usage这本书累计的输入 / 输出 / 缓存读 / 缓存写 / 合计 token 与轮次
POST /library/:book/usage{ reset: true } 把账本归零(别的 body 一律 400 bad-request)

写回正文后补材料:AI 标签的写回条旁边有个默认勾上的「顺便补材料」开关——写回成功后,立刻用这 本书自己的模型读一遍刚写进去的那段,把新出现的人 / 地 / 物 / 规则列成清单。清单默认全勾,只有点 「补充到各区域」才落盘;落盘时只填空字段、tags 取并集,作者已经写好的那一行永不覆盖。识别这 一步本身不写任何东西,但它花的 token 同样记进这本书的账本。

正文里的「识别材料」:章节面板的动作行上多了一个同名按钮,勾选要读的章节再开始——一次读一章, 不必先把正文交给 AI 写回,也不用把整本书扫一遍。正在编辑的那一章读编辑器里的文字(含还没保存的 改动),其它章读已保存的正文;每一章各算一次调用、都记进同一本账,读回来的清单合并成一张表:同一个 条目在几章里都出现时并成一行,先出现那一章给出的值不会被后面的覆盖,只补它没写的字段。清单同样默认 全勾,只有点「补充到各区域」才落盘。

一次提交的正文会被切片读:一次调用要求模型给出一份长 JSON,而长回答正是最容易坏的一种—— 回话被截断或裹上解释,整份清单就解析不出来(表现就是「模型没有按要求给出 JSON」)。所以正文按 1200 字(SLICE_MAX_CHARS)切成片、每片单独问、各自拿回一小份 JSON,再合并成上面那张表。某一片 读不懂就再问一次(SLICE_TRIES);仍然读不懂时它被记成「有 N 个片段没能读懂」写在清单上方, 不影响其它片段——只有所有片段都读不懂才整批报错。plan 上因此多了 slices(片数)与 skipped(哪些片没读懂)。

勾选章节的抽屉里也能换模型:抽屉顶上就是这本书的模型与思考强度表单,和「AI」标签里的是 同一个组件,改一处两处同步;写回正文那一步用的也是这本书的路由。换书或换标签都不会碰到别 的书的设置。

路由作用
POST /library/:book/extract{ chapter?, passage } 或 { chapters: [{ id, passage }] }(一章一次调用,再合并)→ 用本书模型读出 plan(entries / dropped / counts / slices / skipped),不写盘
POST /library/:book/extract/apply{ entries: [...] } 把选中的条目写进各分区,返回 written / updated / skipped

哪些分区是候选由 schema 决定:records 或 doc 且声明了标题字段的单元,章节 / 素材 / 草稿不在其中; 扩展新加的分区自动成为候选。模型给出的区域名或字段名不在 schema 里就丢掉并记进 dropped,select 字段的值必须命中它自己的选项表,一次最多 60 条。

目录来自 host 的 llm.listProviders() / llm.listModels(provider),在 lib/index.js 里由 shapeCatalog() 归一化(30 秒缓存);没有 llm 服务、或某个 provider 列不出来时降级成空目录, 绝不影响已经保存的路由。LlmProviderInfo / LlmModelInfo 的字段名没有钉在宿主契约里,所以 id / key / provider 与 id / model / key 两种写法都读。

命中的 host 服务是 llm(ctx.llm.stream(options))与 agentDefaultModel(currentSelection() 给出当前选的 provider / model)。两个都经 ctx.inject(…) 取得,不进 inject 声明—— 可选依赖不该能让整个面板消失;lib/index.js 把句柄交给可重载的 api 层,所以它们随时可以缺席。

两条设计约束:

  • 开流之前可能失败的一切,先用普通 JSON 答掉。 SSE 头一旦发出,未知章节或未选模型就只能 报成「被截断的回答」。所以 prepareTask(取数 → 拼词 → 认路由)和 streamPrepared(开流) 是分开的,后者只在万事俱备后才被调用。
  • 请求先于部署被判断。 缺参数、未知任务是调用方自己能改的(400);这台机器上没有可用的 llm 服务不是(503)。顺序因此是「取数 → 拼词 → 认路由 → 查服务」,坏请求永远不会被 说成「服务缺失」。

提示词装配在 lib/prompt.js 里是纯函数:同样的输入永远得到同样的提示词,测试不需要联网。 每个区段都有字符预算,且把截断讲给模型听(…(已截断,原文还有 N 字)),避免它自信地编造 被切掉的人物。

检索、章节、草稿、进度、导入

这几块共用同一套约定:读走 GET,写走 POST,凡是会大改文件的都先给一份 dry-run, 业务拒绝一律是普通 JSON(400 参数问题 / 404 没有这个条目 / 409 需要确认),不是半截结果。

路由作用
GET /library/:book/search?q=&limit=&section= 找词;每命中给分区、条目、字段、正文行号与片段
GET /library/:book/chapters读序计划:章节、字数、编号风格,以及断号 / 重号 / 未编号
POST /library/:book/chapters/reorder{ ids: [...] } 把章节摆成这个顺序(必须是当前章节的一个排列)
POST /library/:book/chapters/renumber{ start, style, width, separator, dryRun, retitle } 重编号
GET /library/:book/drafts快照列表,新在前,带章节标题、字数、大小与时间
POST /library/:book/drafts{ chapter, body?, note? } 存一份;body 省略即取章节现文
POST /library/:book/drafts/restore{ name, force? } 退回去;内容有变先答 409,force 才覆盖
DELETE /library/:book/drafts/:name删一份快照
GET /library/:book/progress目标、总字数、今日 / 本周、连续天数、30 天历史、节奏推算
POST /library/:book/progress{ words, deadline } 设目标(null 清除;日期要 YYYY-MM-DD)
GET /library/:book/settings这本书的偏好
POST /library/:book/settings{ theme, autosave, fontSize, wordGoal } 浅合并保存
GET /library/:book/import书里可导入的文件(materials/、publish/、import/ 下的 md / txt / json)
POST /library/:book/import{ kind: 'backup' | 'markdown' | 'file', … },默认 dryRun: true

检索不是全文搜索的简化版,而是把「这本书里所有能写东西的地方」都扫一遍:正文按行报位置, 记录按字段报,素材按文件名报,原始 YAML 按整段报;同一个字段里多个词都命中才算一条, 所以结果不会同一句话报两遍。?section= 可以只看一个分区。

章节顺序是文件名的一部分,所以重排与重编号真的会改文件名——因此采用两阶段改名 (先全部改成临时名再改成目标名),避免两个章节互换时撞名。style 支持 cn(第一章)、 arabic(第1章)、prefix(01-);带着编号的标题只在原标题自带序号时才跟着改, 免得把「第一章 落幕」改成「第三章 落幕」。改完会同步平移 progress.yaml 里的章节键, 不动历史产量,否则重命名会被记成一次亏损。

草稿箱的存在理由是「改之前先留一份」。所以从草稿恢复时若章节已经被改过,先答 409 让你确认; 确认之后它做的第一件事是把当前正文再存成一份自动快照,然后才写回——退一步永远可退。

进度按每日净产出记账,而不是看文件 mtime(mtime 分不清「改了这一段」和「重写了整章」)。 删章节是负数。没有目标时只报已有的事实;设了目标才有「每天要写多少 / 哪天写完」。

设置存 settings.yaml(theme / autosave / fontSize / wordGoal),面板里改的是这几项; 未知键原样留着,所以扩展可以往同一个文件里加自己的偏好而不被清掉。fontSize 是阅读字号: 章节正文、AI 结果、AI 多轮记录、导出预览都跟着它走,所以「字太小」只需要改一个数字。

放大预览给每一处文本一个整窗的读法。面板本身是别人分给它的一个窄列,长文没地方展开—— 导出预览本来就截到 4000 字、AI 结果只能在自己的小框里滚。现在 AI 结果、AI 多轮的每一轮答案、 章节正文、素材文件、整份 YAML、记录里的长字段、导出预览旁边都有一个「⤢ 放大」按钮,点开是一层 覆盖整个窗口的只读视图:按窗口宽度折行、滚动阅读,Esc 或点空白处关闭,A- / A+(或键盘 + / -)调字号、恢复回 1:1、复制全文拿走。字号以本书设置里的阅读字号为基准再乘一个倍数, 倍数存在浏览器的 localStorage(dsh-novel-studio.zoom),下次打开还是这个大小。只读是刻意的: 它是一个视图,不是第二个编辑器,不可能从这里误写回正文。

开发

# 全部 12 套顺序跑一遍(约 10 秒,输出每套自己的逐项结果)
npm test

# 冒烟测试(不需要 DSH 在跑)
node tests/library.smoke.mjs   # 57 项 · 数据层与文件夹浏览
node tests/units.smoke.mjs     # 52 项 · 条目读写
node tests/ops.smoke.mjs       # 99 项 · 操作层与扩展
node tests/graph.smoke.mjs     # 80 项 · 关系图与校验
node tests/export.smoke.mjs    # 70 项 · 导出格式与发布
node tests/edges.smoke.mjs     # 59 项 · 关系的增删改与字段回写
node tests/prompt.smoke.mjs    # 66 项 · 提示词装配、接地、可勾选分区的渲染与预算截断
node tests/ai.smoke.mjs        # 198 项 · 取数、勾选、多轮历史、路由、流式回吐、篇幅要求、token 账本、调用归属、AI 扩展缝(假 llm 桩,不联网)
node tests/extract.smoke.mjs   # 148 项 · 补材料:候选分区取舍、严格 JSON 解析与丢弃、只填空字段、tags 并集、长文切片与重试、按章连读与合并、账本(假 llm 桩)
node tests/bindings.smoke.mjs  # 23 项 · 多值字段(势力成员 / 物品持有者 / 面板归属)与多对多绑定、派生边
node tests/model.smoke.mjs     # 46 项 · 每书独立模型:归一化、继承规则、目录归一化、路由真的进了调用
node tests/studio.smoke.mjs    # 79 项 · 检索、章节重排与重编号、草稿箱、进度记账、导入 dry-run、设置、浏览器半边的文本契约与装载

# 对运行中的 DSH 打真实 HTTP
node tests/http.e2e.mjs        # 207 项 · 真实路由(含 /library/:book/model、/efforts、/extract(单段与按章)、/root 与 /folders)+ 热重载

# 安装到 desktop profile(本地 link)
dsh plugin --profile desktop add link:

npm 包名是 dsh-novel-studio-lyjs(dsh-novel-studio 这个名字在 npm 上已被别人的另一个插件 占用),GitHub 仓库名仍是 dsh-novel-studio。装的时候命令里写的是目录,所以名字差别不影响本地安装。

安装后 dsh.profile.bundles 会加入 dsh-novel-studio-lyjs,node_modules/dsh-novel-studio-lyjs 是指回本目录的