← Back to home@john-walks-slow

dsh-aivn

AIVN stage engine inside DeepSeek Harness: a workspace is a play, the playwright preset writes Stage DSL, and a Stage tab in the conversation renders it live (backgrounds, sprites, dialog, stop-point choices, line-by-line TTS). AIVN 舞台引擎的 DSH 插件。

Stars
0
Language
TypeScript
Created
Oct 7, 2026
Updated
Oct 7, 2026
GitHub repo

Introduction

dsh-aivn

简体中文 · English

把 AIVN 的舞台引擎装进 DSH。

一个工作区就是一部剧目:在里面开会话、选「剧作家」预设,对话页就多一个「AIVN」tab (只给剧作家会话——搭台助手的会话里没有这个 tab), 剧作家写的 Stage DSL 被实时演出来——背景、立绘、台词条、停止点选项都在这里;玩家点一条选项, 这句话回到剧作家手上,它接着往下写。

AIVN tab:背景、立绘、台词条,右下角是语音开关与「重听这句」

装

包是 DSH 的 bundle(package.json 里声明了 dsh.bundle.patch):

dsh plugin --profile web add dsh-aivn

从 GitHub 直装也行——lib/ 是跟踪的构建产物,所以不需要构建脚本、也不会触发 pnpm 的 allowBuilds 拦截:

dsh plugin --profile web add github:john-walks-slow/dsh-aivn
  • 手工挂:在 profile 的 cordis.patch.yml 里加一行:
- insert:
    - id: dsh-aivn
      name: dsh-aivn
      config: {}
  • 客户端半边(「AIVN」tab)挂在 conversation.view 槽上,需要 dsh-web-app。它在 package.json 的 dsh.client{platform:"web"} 里声明,不走 Loader。

可配的有六处——语音、联网检索、生图、配乐、命令行、按预设禁用工具;前五项都不配也能照常跑(只是那几项能力没有,对应工具不注册、提示词不提),禁用项默认空。

两种改法,改完都就地生效(不用重启 dsh):

  • 界面(推荐):侧边栏 Plugins → dsh-aivn 里的配置卡。分两段——P0 决定能力开不开(Key 与后端),P1 是默认就能用的口味项;顶部一行状态灯显示当前哪几项能力是开的。密钥只写不回显,卡片上另有「清除已存 Key」。
  • 配置文件:profile 的 cordis.patch.yml。界面保存的也是这里边——两种改法落在同一份配置上。
- id: dsh-aivn
  name: dsh-aivn
  config:
    ttsKeys: ["<your-fish-audio-key>"]     # 多把可轮询,摊免费额度(界面上逗号分隔)
    ttsBaseUrl: https://api.fish.audio      # 默认值,一般不用写
    ttsModel: s2.1-pro-free                 # 默认值
    ttsConcurrency: 2                       # 同时合成的上限
    searchKeys: ["<your-exa-key>"]          # 给了它搭台助手才有 web_search
    searchBaseUrl: https://api.exa.ai       # 默认值
    shell: false                            # true = 给搭台助手放开 bash
    imageFormat: gemini                     # gemini | openai | modelslab(接口格式,不是产品名)
    imageBaseUrl: http://127.0.0.1:38000    # 本机 flow2api;官方 API 或别的网关填它自己的地址
    imageModel: gemini-3.0-pro-image
    imageSize: 1K                           # 1K / 2K / 4K(K 大写),或字面像素 1536x1024
    imageApiKey: ""                         # 本机网关不校验就留空(留空 = 不带鉴权头)
    imageTimeoutMs: 180000
    musicBaseUrl: http://127.0.0.1:38000
    musicModel: flow-music-lyria-3.5
    musicApiKey: ""
    musicTimeoutMs: 240000
    playwriterDisabledTools: ""             # 按预设禁用工具(逗号分隔的工具名),默认空 = 两预设一致
    stagehandDisabledTools: ""

配置面是扁平的 19 个字段(DSH 设置界面的控件只认顶层字段,嵌套写法它寻址不到):

配置项默认给了会怎样环境变量兜底
ttsKeys空舞台有语音开关,两个预设多一个 list_voices(音色库与合成共用这池 key)DSH_AIVN_TTS_KEYS
ttsBaseUrl / ttsModel / ttsConcurrencyFish 官方 / s2.1-pro-free / 2——
searchKeys空两个预设多一个 web_search(一次调用同时拿回结果与正文)DSH_AIVN_EXA_KEYS
searchBaseUrlhttps://api.exa.ai换代理 / 镜像时改—
shellfalse两个预设的 tool-bash 行真装上(默认关,与 AIVN 的 defaultOff 同口径)。bash 是预设的「一行」,只对新建会话生效—
imageFormat / imageBaseUrl / imageModel—地址与模型都给齐才有 generate_image / generate_asset(两个预设都有);cut / import_asset 不依赖它。格式是接口协议形状,不是产品名—
imageSize1K档位 1K/2K/4K(K 大写)或字面像素 1536x1024;gemini 格式只认档位,openai/modelslab 两种都收(modelslab 封顶 1024)—
imageApiKey / imageTimeoutMs空 / 180000本机网关不校验就把 key 留空(不带鉴权头);出图慢就把超时调大—
musicBaseUrl / musicModel—都给齐才有 generate_bgm(只有 Gemini 形状,没有 format)—
musicApiKey / musicTimeoutMs空 / 240000同上;一首曲子实测约 84 秒—
playwriterDisabledTools / stagehandDisabledTools空按预设禁用工具(逗号分隔的工具名,取值为「Agent 工具」清单里的工具名)。默认空 = 两预设工具面完全一致—
抠底调参见「素材生成」DSH_AIVN_CUTOUT_TOLERANCE / _KEY_SMOOTH / _EDGE_BAND / _SPILL / _SOLID_RESIDUAL 五个环境变量同名环境变量

key 两处都没有 = 那项能力停用:工具不注册、提示词里也不提。两个预设、一条 /new-play 命令、 /aivn 路由都是固定的,不可配。

改完的配置由 loader 原位提交进运行中的引用,插件据此重建能力面:那一轮的工具装/卸、搭台助手 提示词里门控的那几章、语音后端,开着的会话下一轮就用新的;唯一例外是 shell——它翻的是预设行集, 已开着的会话保持原样,新建一个才吃到。

开发期 @aivn/core 与 @aivn/stage 走 file: 依赖(指向 stage-ai 仓库)。发布版换成版本号。

用

  1. 新建会话,在座位 chip 上选 剧作家(备料的那位是 搭台助手)。 预设是在会话开始时定下的,中途换不了——想换就新建一个会话。
  2. 会话的工作目录根要有 play.json。没有的话舞台会明说「这个会话的工作目录里没有 play.json, 还不是一座剧目」——那就先把它立起来,见下。
  3. 说一句话开演。之后的一切都在舞台上发生——那句话会把剧目的开局指令(opening)一并带给剧作家, 所以不必照着剧本念开场白,随便说句什么(「开始吧」)就行。

「AIVN」tab 是会话头部的第三个 tab(Chat / Trajectory / AIVN),只在这个会话是剧作家预设时出现。

导演工具栏(提示 / 改写 / 重写)

舞台右上角那排键是给导演用的,三轮动作都走宿主侧的同一条路:

动作它会做什么什么时候能点
提示 · 引导把这句话排进剧作家的下一轮。正在演的这一拍不受影响,停止点还在,玩家照选不误随时
提示 · 打断先停下正在写的这一轮,再用这句话接着写随时
改写把最后一步写出来的内容作废,让剧作家按你交代的那句重写一遍(弹窗里预填的是当前这一句,改完提交)不在写的时候
重写把这一拍整段作废,让剧作家重新写(指令可留空 = 纯重写)不在写的时候

两件要紧的事:

  • 改写 / 重写和 AIVN 那边的同名功能不是一回事。AIVN 有一份自己的脚本模型,「改写」是直接改 那一行脚本、不经过模型;DSH 这边的剧本就是剧作家的输出、会话日志才是权威,所以「改这一句」 只能是让剧作家重写这一拍:插件往会话里追加一条替换标记(surfaceOp.replace,与 dsh-rewind 同一个手法),被作废的那一段从上下文里真的消失,模型看不到旧内容。 代价是一次重写要花一轮 LLM(AIVN 的原地改写不花),换来的是上下文不会和画面对不上。
  • 生图不在这一排:出图在搭台助手那条会话里做(generate_asset / generate_image),舞台这排键点了也没有落点, 所以不摆。

导演说的话带自己一支来源(dsh-aivn-director),不会变成一行玩家台词:舞台的时间线上 只有玩家真正说过的话。剧作家那边由系统提示词的《演出契约》第 7 条交代「(导演…」是片场喊话。

回退与分支:舞台跟着会话走

  • 回退(dsh-rewind 的「回退到这里」):它往会话里追加一条替换标记,插件收到就把舞台 整段重投影一遍——回退之后刷新或重新打开,「AIVN」tab 演到的就是你回退到的那个点。
  • 在新对话中分支(DSH 原生的分支):新会话带着父会话的前缀,插件这边不做任何事—— 第一次打开它的舞台时,帧流里一帧都没有,插件就从会话面重铺一遍,于是新会话停在分叉那一刻 (连同那一刻的停止点)。
  • 插件不做自己那一套谱系分支(AIVN 的路线图 / 旧枝那套):要分岔就用 DSH 原生的两样。

进行中浮层

右上角那枚「进行中」徽标(有东西时才出现)里是两样:排队中的提示(还没进会话的那些) 与正在合成的语音。出图与配乐不在里面——插件里它们是同步的工具调用,没有回合之外的作业可报。 玩家按了 HIDE 时它跟着舞台一起收起来。

起一座新剧目

工作区里没有 play.json 就是「这还不是一座剧目」:写一份出来就是立起一座剧目,没有生成器。 字段与布局写在两个预设的提示词里(《剧目文件》那一章,同一段文本),模型用文件工具直接写。

  • 人:输入框敲 /new-play 雨中的旧校舍。不给剧目名就取工作目录名——这一步替你写 play.json 与骨架文件,不花一次 LLM 往返。
  • Agent:照《剧目文件》写。搭台助手备料时就顺手立起来了。

/new-play 只写不存在的文件:play.json 已经在那儿就直接报错(这里已经是一座剧目, 要起新的就换一个空目录开会话),memory/always/premise.md 与角色卡已经写好就跳过、 在回执里列成「保留」。落下的骨架就是下面那张目录表里的前几行——之后缺什么补什么。 写完想知道对不对,让搭台助手调 validate_play。

一部剧目长什么样

工作目录根就是剧目根:

play.json                          剧目配置(唯一的必填文件)
memory/always/premise.md           世界观前提
memory/always/craft.md             文风与禁忌(散文,只能拿话说)
memory/always/state/scene.md       当下场景(剧作家自己写)
memory/always/state/threads.md     未了线索(剧作家自己写)
memory/always/state/state.json     好感度与旗标(同上:好感度是 0~100 的整数,键是角色 id)
characters/<id>.md                 角色卡
assets/backgrounds/…               背景
assets/cg/…                        插图
assets/sprites/<id>/…              立绘:每个主体一个目录
assets/sfx/…  assets/bgm/…         音效与音乐
assets/manifest.json               素材描述表:素材 id → 一句话画面
endings.json                       结局账本(引擎维护、跨周目累加;不要手改)

play.json

必需字段只有两个:

{ "id": "my-play", "title": "我的剧目" }

其余字段(全部可选):

字段作用
opening开局那条 user 消息的正文。玩家在这条会话里说第一句话时,它与那句话一起发给剧作家;空舞台上的「开演」按钮发的也是它。缺省 (游戏开始,请演出第一轮)
characters[{ id, name }]。只用来给台词条配名字:立绘绑定在角色卡的 frontmatter 上
craft写作参数,见下
initialState / initialScene剧目的起始状态与起始场景
newGamePlus多周目开关(缺省 false)。开启后,同工作区新开的会话(新周目)会在提示词里注入此前已达成的结局,见「结局与多周目」
scriptLanguage剧本语言(ISO 639-1)。缺省跟随玩家说什么话
voiceLanguage / defaultVoiceId / cover语音与封面:属于 AIVN app,本插件当前不读
image{"model": "…", "size": "2K"}:这座剧目自己的出图模型与档位,覆盖部署级设置(两个字段各覆盖各的,留空即跟随部署配置)。比如一部剧的立绘要 2K 抠底、另一部的小剧场插图 1K 就够
agentsagent 运行设置:属于 AIVN app,本插件当前不读

结局与多周目

故事走到真正的终点时,剧作家把那一轮的最后一行写成结局标签(它取代那一轮的 <stop>):

<ending id="true_sunrise" title="晨光" subtitle="这一次,她没有回头"/>

到达结局后舞台进入终局态:不给任何按钮、不能继续,想再玩只能走 DSH 的「在新对话中分支」 (回到结局之前)或新建一条会话。结局之后引擎会单开一轮请剧作家写一段收束散文 (<epilogue>…</epilogue>),流式填进结局卡。

结局由引擎自动记到工作区根的 endings.json:跨周目累加、按 id 去重、reachedIn 记它在第几周目 达成。这份账本引擎独占,不要手改。

play.json 打开 newGamePlus 后,同工作区新开的会话(新周目)开头会把此前已达成的结局注入 剧作家的提示词,让它安排多周目要素(角色隐约记得、某些路线 / 选项不同)。引擎只保证账本可见, 不强制解锁任何内容——怎么用完全由剧作家决定。新周目开始时引擎会把 memory/always/state/ 三个状态文件重置回起点(premise.md / craft.md / 记忆索引 / 角色卡都不动)。

characters/<id>.md

一张角色卡就是一份 Markdown,可选 frontmatter:

---
name: 林
sprite: lin
voice: 短句,少用形容词
---

十七岁,话少,习惯先看窗外再看人。
  • name —— 台词条上的名字。
  • sprite —— 绑到哪个立绘目录(assets/sprites/<sprite>/)。不写就是角色自己的 id。 立绘是立绘自己的事:characters/ 可以没有立绘,sprites/ 也可以没有卡。
  • body —— 人设正文。

assets/manifest.json

剧作家认 id 认不出画面,靠这份描述:

{
  "veranda": { "description": "黄昏时分的走廊,木地板反着暖光" },
  "lin/normal": { "description": "林,常服,平静的神色" }
}

键是素材 id,不是路径——不带目录前缀,也不带扩展名。写成 backgrounds/veranda.png 不会报错,只是永远匹配不上,剧作家会以为这件素材没有描述。三种键形:

素材键
背景 / 插图 / 音效 / 音乐文件名去掉扩展名(veranda)
立绘(整主体)立绘目录名(lin)
立绘(单个差分)<立绘目录名>/<差分名>(lin/normal)

值里除了 description 还能写 tags、title、framing、stature、anchor (立绘摆位),音乐还能写 mood、scene、durationSec、loop、volume。

背景、插图、音效、音乐按扩展名白名单收(png/jpg/jpeg/webp/mp3/ogg/wav/m4a);立绘按 sprites/<主体id>/ 分目录,每个目录里的第一个文件是找不到指定差分时的兜底。

craft:写作参数

play.json 的 craft 段,三个字段都可不写(不写 = 引擎默认):

字段取值默认
beatLengthshort / medium / longmedium
stopOptionstwo / three / four / freethree
assets.backgroundlibrary-first / library / generatelibrary-first
assets.cglibrary / generate / offgenerate
assets.spritegenerate / offgenerate
assets.audiolibrary / offlibrary

free = 固定停在自由输入框,不给选项面板。

素材来源四类是给剧作家的意向指令:引擎按这四项把「缺素材时怎么办」写进剧作家的《写作参数》——例如 cg: off 让它别写 <cg>、background: generate 让它自己出图。注意两点:

  • 没有跨剧目素材库(应用级 library/ 永久不做):library / library-first 里「用库」那一支永远走不到,会退化成「用本剧目已有的 / 出图」;
  • 生图那几支还要看有没有配生图后端:没配时会自动改成「用已有素材或旁白交代」,不会教剧作家去调一个不存在的工具。

文风、禁忌、称呼习惯这类只能拿话说的口径写在 memory/always/craft.md,不在 play.json 里。

这一段用 edit 定点改(只动 craft 这一个键,文件里其余部分一个字节都不碰)。工具里没有 set_craft:写文件就是写文件。写错的值引擎静默当没写——改完让搭台助手调 validate_play, 它会把不认的取值报出来。

theme.json:舞台皮肤

剧目根可以放一份 theme.json,改这座剧目的舞台外观:键就是 @aivn/stage/stage.css 里 .stage-root 上的 CSS 变量名,值过白名单校验。不放这份文件就是默认皮——一个变量都不注, 画面与从前逐像素一致。

{
  "--dialog-bg": "linear-gradient(178deg, rgb(16 14 20 / 0.94), rgb(8 7 10 / 0.96))",
  "--dialog-ink": "#f4eee0",
  "--stage-bg": "#0b0a12",
  "--stage-radius": "6px",
  "--accent": "#8a5cf6",
  "--accent-ink": "#f7fbff",
  "--font-ui": "\"Noto Serif CJK SC\", serif"
}

十七个可设置的键,分五组:

组键取值
台词条--dialog-bg颜色,或 linear-gradient(<0–360>deg, <颜色>, <颜色>)
--dialog-line、--dialog-ink、--dialog-ink-soft、--dialog-ink-thought、--dialog-ink-faint颜色
--dialog-shadownone,或 0 10px 30px rgb(0 0 0 / 0.45) 这样的「x y 模糊 颜色」
衬底与圆角--stage-bg颜色(图片未到或加载中露出的底)
--stage-radius0px–24px
--sprite-dim0.2–1(1 = 非发言人立绘完全不压暗)
选肢卡--choice-bg、--choice-line、--choice-ink颜色
字体--font-ui逗号分隔的字体名,整串 ≤ 200 字符
主色--accent、--accent-ink、--accent-soft颜色

颜色写法:#rgb / #rrggbb / #rrggbbaa / rgb() / rgba() / hsl() / hsla()(空格与逗号两种 参数语法都认)。不认颜色名、var()、calc()、url()——这条通道收的是值,不是 CSS 文本。

两个入口:

  • 让搭台助手改:把想要的基调说清楚,它调 set_stage_style 写文件,写完立刻把新皮肤推给 同一座剧目已经打开的舞台(不用刷新)。不给参数调用一次 = 看当前生效值与默认值对照。
  • 自己手改:改完刷新页面或切一次会话生效(手改不广播)。

白名单外的键与非法值,手改时只丢那一项,工具调用时整次拒绝(一个字节都不写);文件整个坏掉时 舞台回默认皮、剧目照常开演。

两个预设

id名字干什么
aivn-playwriter剧作家实时写 Stage DSL 的那位;它的输出被接进舞台
aivn-stagehand搭台助手备料:写设定、角色卡、创作口径、挑音色、出图与入库、配乐、查资料

两个预设的行集同构:指令文件发现(AGENTS.md)+ persona(就是那份提示词)+ 文件工具 + 文件检索 + 技能库(自带的 skills/,见下)+ 一行默认关的 tool-bash(shell: true 才真装上)。 工具面也完全一致(见「Agent 工具」),角色差别只剩 persona、注入内容与舞台管线。 预设里不会再列一行 dsh-aivn——宿主已经装了本插件,再来一行会把路由与事件订阅重复装一遍。

只有剧作家预设的会话会进舞台管线。搭台助手说什么都不上台。

技能库(两个预设)

插件自带三份做法速查,随包发布(skills/),装进两个预设的会话:

技能讲什么
aivn-play-setup起新剧目 / 改设定的四步做法与各文件写法(premise / craft.md / craft 段 / 角色卡 / 记忆卡 / 素材表)、craft 取值、备料顺序
aivn-visual-craft画风锚点写在哪、角色外形怎么锁(头身比 / 五官 / 服装)、差分布局与命名、图单怎么写、可直接粘贴的生图提示词写法
aivn-audio免费 BGM / 音效去哪里找、哪档授权能商用、六大场景与常见音效类别的选源、再分发与 AI 训练红线

清单进 system prompt(只列名字与一句话),模型觉得对上了才加载全文——三份都是长篇,不该每轮全读。 includeDefaultRoots: false 是刻意的:宿主上那几十个本机运维技能不该挤进这两个预设的清单。

上下文注入

剧作家的系统提示词由两部分拼成,中间夹着每轮注入的剧目上下文——剧作家不需要自己去 ls + read 一圈就知道这部戏的设定、有谁、有什么素材、现在演到哪儿:

段内容来源
静态前半身份、怎么工作、剧目在哪persona 的 prefix
《剧本语言》正文用哪种语言写play.json 的 scriptLanguage
《剧目设定》世界观前提memory/always/premise.md
《角色表》每张卡的正文 + 音色 + 立绘差分;只有立绘没有卡的主体单列一节characters/*.md
《素材清单》背景 / 音乐 / 音效 / 插图的 id + 描述;有音乐音效时附编排规则assets/ + assets/manifest.json
《写作参数》每轮多长、给几条选项、素材来源,外加创作口径散文play.json 的 craft + memory/always/craft.md
《记忆索引》每张设定卡一行(分类 / 标题 / 路径 / 摘要)memory/index/**
静态后半剧本格式、记忆卡怎么写、引入新角色、演出契约插件注册的尾段
【状态】场景、好感度、旗标、活跃剧情线(每轮变化的 user 角色快照)memory/always/state/*

这些段只挂给剧作家的会话(注册在 Agent scope 上),搭台助手看不到舞台契约。 快照按秒级 TTL 缓存:一个 turn 里的多个 step 读到同一份,跨轮自然重读。 改完文件不用做别的,下一轮就生效。

搭台助手的注入是另一套(同样注册在它自己的 scope 上):

段内容来源
《剧本语言》给演出看的内容用哪种语言写(与用户对话仍是中文)play.json 的 scriptLanguage
《当前状态》剧目文件清单(路径 + 大小,三层深)+ 开演条件报告工作目录 + readiness

它不需要舞台契约(不写剧本),也不需要角色表那种逐条展开——它要的是「现在盘上有什么、 还缺什么」,刚写完文件下一轮就看得见。

Agent 工具

工具跟着预设装、跟着预设卸。validate_play 两个预设都有;其余按角色分。 剧作家的停止点不是工具:它是剧本正文最后一行的一个标签——<stop options="甲 | 乙"/>(选项面板)、 <stop placeholder="…"/>(自由输入框)、<stop/>(这一段自然演完)。轮尾因此是一条助手消息, DSH 的「在新对话中分支」与舞台重建(只重放助手文本)都认得上。故事真正的终点改用 <ending/> (同为末行标签、取代那一轮的 <stop>,见「结局与多周目」)。 模型用文件工具就能做的事一律没有:play.json、craft 段、memory/always/state/ 都是文件工具 写出来的,写成什么样由提示词说清(见上「起一座新剧目」);「这座剧目有什么素材」也一样—— 剧作家的 A 区每轮注入《素材清单》与《角色表》(id + 描述),搭台助手看《当前状态》的文件清单、 要描述就 read assets/manifest.json。

两个预设装同一套 AIVN 工具(工具清单见 src/tool-catalog.ts);「门控」列只有配了对应后端 / key 才注册的项。 按预设禁用某几个工具,用插件配置 playwriterDisabledTools / stagehandDisabledTools(逗号分隔的工具名,默认空 = 两预设完全一致)。

工具门控作用
validate_play—剧目自查:play.json 读不读得出来(缺字段、坏 JSON 会给原因)、craft 里有没有引擎不认的取值、状态文件好不好、故事前提、角色卡张数、立绘、背景、素材条数、出图台账里有没有指向不存在文件的登记。刚写过剧目文件之后调它。没有硬门槛,缺什么都能开演
set_stage_style—写剧目根的 theme.json(舞台皮肤:台词条、选项卡、衬底、圆角、字体、主色),写完把全量皮肤推给同剧目已打开的舞台。三态:省略 = 不动、给值 = 改、null = 回默认;不带参数调用 = 看当前值。值格式与可设置的键见上
generate_image生图后端纯生成:一段画面描述 → 一张候选图(回执给文件路径与预览),返回时不绑定任何剧目目标、不抠底、不登记。画幅用 aspectRatio(默认 16:9);references 可垫主体 id / 剧目内路径 / http(s) 网址。落成素材另走 cut / import_asset(或直接 generate_asset)
cut—抠底:纯色底的图 → 透明 PNG(只写输出,不改源图)。source 给剧目内相对路径,output 不给就写到候选区。五档调参见下(tolerance / spill / edgeBand / keySmooth / solidResidual)。立绘登记前先跑它;抠脏了就对着同一张源图重调再跑。不依赖生图后端
import_asset—把一张图登记成剧目素材:写 assets/、补素材表(呈现声明 + 描述)、记台账,不抠底。source 给剧目内相对路径或 http(s) 网址;落位显式给——kind(background / cg / sprite)必填,立绘再给 spriteId / variant / framing / stature / title。立绘要传已抠好的透明 PNG(外部来源先 cut);original 可留抠底前原片以便重抠。不依赖生图后端
generate_asset生图后端一键:出图 +(立绘)自动抠底 + 登记,一次完成,回执给最终素材路径。参数与 import_asset 的落位一致(kind + name / spriteId / variant / framing / stature)。只有这条路由引擎自动做色键底、身份锁、差分垫 neutral;同名素材已有则直接跳过
generate_bgm音乐后端生成一首 BGM 落 assets/bgm/;同步等待(约 84 秒),name 就是剧本里 <scene bgm="…"> 引用的 id
list_voicestts.keys查 Fish 音色库挑音色:按语言 / 标签 / 标题关键词取窗口,本地按 gender 筛,按热度排序出前 25 条。挑好的 id 填进角色卡的 voice
web_searchsearch.keys联网检索:一次调用同时拿回结果与正文。只查剧目之外的事实——设定、角色、剧情都在工作区文件里

list_voices / web_search / generate_image / generate_asset / generate_bgm 在没配对应 key / 后端时 不注册,提示词里也不提它们。validate_play / set_stage_style / cut / import_asset 不门控: 它们只读写剧目文件、跑抠底,不碰生图后端——所以没配生图也能用外部工具出图、cut 抠底、import_asset 登记。 文件工具(read / write / edit / glob / grep / read_image)由预设的行集提供,两个预设都有。

素材生成

「像素从哪来」和「像素怎么变成剧目素材」是两步,工具也是分开的:

  • 一键(generate_asset):出图 +(立绘)自动抠底 + 登记,一步到位——「就想要一张能用的图」走这条。
  • 原子三件套(generate_image / cut / import_asset):各自单一职责,可自由组合—— 要出候选让用户挑、图来自别处(下载 / 别的模型)、或要单独重抠时走这条。
一键(generate_asset)原子(generate_image → cut → import_asset)
来源配置的生图后端generate_image 的候选 / 剧目内路径 / http(s) 网址
剧目感知自动做色键底、身份锁、差分垫 neutral、抠底无:立绘的纯色底要自己在 prompt 里写清,抠底单独调 cut
落位参数显式给(kind / 主体 / 差分 / 取景体量)import_asset 的参数显式给
入库一步到位分步:cut 抠底 → import_asset 登记

出图同步等待(一次 70–140 秒)。「一次出多张候选让用户挑」= 同一目标多调几次 generate_image, 把预览贴给用户;挑中的那张再落位——背景 / 插图直接 import_asset,立绘先 cut 再 import_asset。 没被挑中的候选不在素材表里留痕。

目录与台账

play/
├── assets/                       ← 剧目素材,进 git
│   ├── generated.json            ← 出图台账:每张图当初用的 prompt(只增,不覆盖别人的键)
│   └── manifest.json             ← 素材描述与立绘呈现声明(framing / stature / title)
└── media-cache/                  ← 中间物,不进 git,整目录删掉不心疼
    ├── drafts/<草稿id>/{image.*, source.*, draft.json}
    ├── sprite-sources/<主体id>/  ← 立绘抠底前的原片(`import_asset` 的 `original` 与自动补 neutral 落这里;重抠从它取)
    └── tts/                      ← 语音缓存(内容寻址)

草稿留 7 天,由下一次出图顺手清掉。.gitignore 建议至少加一行 media-cache/ (assets/ 要进 git——那是剧目内容)。

候选图怎么给用户看

不新增 HTTP 路由:回执与回复里贴 markdown 图片,路径是工作区内的绝对路径 (![候选](/abs/path/to/play/media-cache/drafts/<id>/image.png))。DSH 的对话界面会把它渲染成 内联预览(点开有大图),草稿与素材都在会话工作区(= 剧目根)里,所以这条路对草稿与入库后的 素材一样有效。工具回执里直接给好这一行,persona 要求模型把它贴进回复——「出 3 张候选让用户挑」 这条流程的前提就是用户真的看得见那三张。

立绘:取景、体量与抠底

  • 取景 framing 决定出图画幅与舞台摆位:full 9:16 / half 3:4 / square 1:1。 人物用 full 或 half,猫、道具这类非人主体用 square(「全身/半身」是给人形用的词)。 同一个主体要保持同一档:声明写进 assets/manifest.json 的 <主体id> 键(整个主体共用的那一层), 某条差分与它不同时另写一条 <主体id>/<差分名>。立绘级取景以 neutral 那次为准—— 它是所有差分的垫图基准,一条 closeup 不该把整个主体的摆位带跑。
  • 体量 stature(small / normal / large / huge)只管台上站多大,与取景是两件事: 机甲是「全身取景 + 巨大」,猫是「方形取景 + 小」。
  • 抠底:立绘必须是透明 PNG。走 generate_asset 时引擎自动抠(色键底 + 全局色键 + 边缘覆盖率 反解,src/media/cutout.ts);走原子路径时在 generate_image 出候选后调 cut。抠不干净时不要重新出图—— 重画出来是另一张画:对同一张源图重调 cut 的参数再跑(源图在 media-cache/sprite-sources/<主体id>/, 或 import_asset 时用 original 留底)。五档调参对应五个环境变量:
环境变量默认作用
DSH_AIVN_CUTOUT_TOLERANCE48色键容差。调大 = 抠得更狠,也会连发丝一起啃掉
DSH_AIVN_CUTOUT_SPILL20限色:压掉边缘残存的底色(发丝一根不少)。边上还有底色残留先调它
DSH_AIVN_CUTOUT_EDGE_BAND4反解带宽,边缘出现硬白圈时加
DSH_AIVN_CUTOUT_KEY_SMOOTH0.8色键降噪:源图 JPEG 环纹在轮廓上啃出缺口时加
DSH_AIVN_CUTOUT_SOLID_RESIDUAL48实心判据。描边被吃掉时调小它

三种生图格式差在哪

format端点垫图尺寸
gemini/v1beta/models/{model}:generateContent✅ 多张(inlineData,官方 / flow2api / cpa 都认)只认档位 1K/2K/4K
openai/v1/images/generations❌ 这个接口没有参考图入参,带垫图直接报错字面 WxH(档位由插件算,守住官方上限)
modelslab/images/text2img 与 /img2img✅ 一张(先传 base64_to_url 换托管链接)单边封顶 1024,档位按长边顶满

垫图是角色一致性的唯一可靠手段:非 neutral 的立绘自动垫该主体已入库的 neutral 定妆照 (prompt 措辞锁不住脸)。所以同一个主体的差分顺序是:先出定妆照、挑一张入库,再派生别的差分。 哪个格式拿不到 2K,立绘的抠底锯齿就只能靠分辨率压——要高质量立绘就用 gemini。

垫图(references)收三种写法:主体 id(自动用它已入库的 neutral 立绘)、剧目内相对路径、 http(s) 网址(现下到内存里,不落盘:带内网/回环/云元数据拒答,每个重定向跳都重判,单张上限 20MB)。背景与插图垫多个主体时,引擎会把「第几张是谁」写进提示词——图片本身没有名字,不点明 的话多人同框会各画各的:不报错、图也好看,只是七濑长成了澪。

剧作家这条路上另有两道自动处理:

  • 已有同名素材直接跳过:回执告诉你「已经有了」并给出引用写法,既不覆盖旧图也不烧配额;
  • 差分缺定妆照时先自动补一张 neutral(一批并发只补一次,回执里把这张一起给你)。 但如果该主体已经有别的差分、只缺 neutral,会报错让你先过目——新补的脸与旧差分不是 同一个人,演出中会静默换脸。

逐剧目的模型与档位走 play.json 的 image 段({"model": "…", "size": "2K"}), 不填就跟部署级设置;这个覆盖对两个角色的出图都生效。

斜杠命令

命令作用
/new-play [剧目名]把当前工作目录立成一座剧目(写 play.json 与骨架文件)。不花 LLM 往返

命令对所有会话可见(DSH 的命令不分预设),在哪个会话里敲就在那个会话的工作目录里落地。 它只新建、从不覆盖:已经有 play.json 就报错,其余骨架文件已存在就跳过并列进回执。

宿主 HTTP 面

浏览器半边只跟这几个口说话,全部按会话分频:

路由说明
GET /aivn/health{ ok: true, plugin: "dsh-aivn" }
GET /aivn/play?session=这个会话的剧目:{ dir, config, voice, theme };不是剧目就是 { dir: null, voice: false }。voice 说明宿主配没配 TTS,客户端据此决定显不显示语音开关;theme 是剧目皮肤(CSS 变量名 → 值),空对象 = 默认皮
GET /aivn/assets?session={ index, manifest };不是剧目 404
GET /aivn/asset?session=&path=剧目内的一件素材。path 是相对剧目根的路径(如 assets/backgrounds/x.png),越界 400
GET /aivn/audio?session=&file=一句台词的合成音频。文件名是 sha1(模型 + 音色 id + 文本),内容寻址,所以可以长缓存
GET /aivn/stream?session=SSE,event: frame,data 是一帧 StageFrame JSON。连上先补推已经播过的帧,15 秒一次 keepalive
POST /aivn/input { session, text, silent? }玩家的一次输入,投给剧作家。silent: true = 不算玩家说的话(舞台的「继续」走这条),仍然会作为一条 user 消息投出去;会话已结局时回 409(终局态不接受继续,出口是分支或新会话)
POST /aivn/direct { session, action, text, from? }导演的一次动作。action ∈ guide(提示·随下一轮)/ interrupt(提示·打断)/ rewrite(改写或重写);rewrite 的 from ∈ line(作废最后一步)/ beat(作废整拍,缺省)。这些消息带 dsh-aivn-director 来源,不当玩家台词
POST /aivn/voice { session, enabled?, paused? }语音总开关与客户端背压。enabled: false 让服务端停止分句合成(省额度),paused 是播放层积压时的临时背压

StageFrame 有六种:

{ seq, kind: 'ir',    event: StageEvent }   // DSL 解析出的 IR
{ seq, kind: 'beat',  state: 'start' | 'end' }  // 剧作家起笔 / 停笔
{ seq, kind: 'voice', lineSeq, phrase, state: 'started' | 'ready', url? }  // 语音
{ seq, kind: 'style', theme: Record<string, string> }  // 剧目皮肤(全量快照,幂等)
{ seq, kind: 'reset' }                      // 前面推的一切作废,紧接着的帧才是现在的样子
{ seq, kind: 'guide', items: { id, text, status: 'pending' | 'sent' }[] }  // 提示队列(全量快照)

style 帧推给同一座剧目的所有会话流:写皮肤的是搭台助手,看舞台的是剧作家会话, 只推自己那条流用户就得刷新才看得见。

reset 是重投影:回退、改写 / 重写、分支会话第一次打开时,宿主把会话面(session.surface, 模型真正看得见的那串事件)重新铺成一条完整帧流推过来。客户端认出它就把播放层归零、按 resetToken 整段重放,再逐帧应用后面那些。seq 只增不回退:客户端按「seq 比上一帧大」去重,重建帧的号 必须比之前那个大,归零会让它们被整批丢掉。提示队列的 guide 帧紧随 reset 之后:队列在导演手里、 不在会话面上,重置之后要跟着回来。

语音跟着播放头的节奏走:一句台词的音频要 2–5 秒才合成回来(Fish 免费模型的实测值), 而舞台是手动推进的——停在这一句上的时间够长就听得到,点太快点过去的那一句会被丢弃 (这是引擎本来的设计:不给已经翻过去的行补声音,免得和下一句叠在一起)。 播放层的「重听这句」是对应这条的补法。

合成的音频落在剧目自己的 media-cache/tts/:文件名是 sha1(模型 + 音色 id + 文本),内容寻址, 所以重连补推、快进回看、重听都命中同一份、不会再打一次上游。这份缓存只增不减(换了模型或改了 一个字就是新文件),随时可以整个目录删掉,下次演出会重新合成。

voice 帧的 lineSeq 是那一行 say_start 帧的 seq(不是本帧的 seq)——音频按它挂回台词行。 started 用来让喇叭亮起来(还在合成),ready 带上可播的 URL。台词与音频是并行出的: 文字先上屏,声音好了接上,接不上就静音演完(合成失败只打一行告警,不阻塞演出)。

玩家输入不在这条流里由路由写入:/aivn/input 只负责投递,落点只有一处——宿主监听 agent/inbox/inserted,于是 DSH 原生输入框与舞台的选肢卡走同一条路,既不会各写一条,也不会漏写。 引擎自己投的那些(追收束的内部指令、silent 的空推进)也走这条路,按 message.id 认出来摘掉, 不会变成一行玩家台词。

/aivn/* 是插件自己注册的前缀路由,在 DSH 的浏览器信任栅栏之外(那道栅栏只管 /api): POST /aivn/input 与 POST /aivn/direct 只收同源请求(Origin 与 Host 对不上就 403)且要求 content-type: application/json,免得跨站页面替玩家投一句输入、或者替导演作废一段上下文。

权限与兼容

  • 网络:出网只有四处——配了 TTS key 时打 Fish Audio(api.fish.audio 或你配的 baseUrl, 音色库查询与语音合成共用),配了 search.keys 时打 Exa(api.exa.ai 或你配的 baseUrl), 配了 image 时打生图后端,配了 music 时打音乐后端(后两者的地址完全由你配的 baseUrl 决定, 默认不预置任何厂商地址)。四项都没配就一个请求都不发。其余功能全在本地。
  • 文件:读写在会话的工作目录(也就是那座剧目的目录)内。工具与 /new-play 只在你指定的 目录里建剧目文件;语音缓存、出图草稿、立绘留底原片写在 <剧目>/media-cache/ 下 (都是中间物,可随时整目录删除)。不碰工作目录以外的路径。
  • 命令行:shell: false(默认)时搭台助手那一行 tool-bash 是关的,它没有命令行; 开成 true 才有——那时它能跑的东西不再受文件工具的路径约束,自己权衡。
  • HTTP:往 DSH 的 web server 注册 /aivn/*(剧目读写、素材、SSE 事件流、音频)。 四个写接口都走同源 + content-type: application/json + 字节上限 + JSON 四道闸; /aivn/audio 只接受目录内的纯文件名(basename 判定 + .mp3 白名单),不接受任何路径。
  • 密钥:TTS 与 Exa 的 key 只从配置或环境变量读,不进日志、不进 URL、不进 git;错误文案里 最多带上游返回的前 200 字符。
  • 兼容:宿主半边对齐 dsh 0.1.7-rc.2,客户端半边对齐 0.1.1-rc.2。「AIVN」tab 需要 dsh-web-app(挂在 conversation.view 槽上)。

开发

前置:@aivn/core 与 @aivn/stage 现在走 file: 依赖,指向兄弟仓库 stage-ai 的 main 检出(两个包都在它的 packages/ 下):

~/projects/
├── dsh-aivn/            ← 本仓库
└── stage-ai/
    ├── packages/core/    (@aivn/core)
    └── packages/stage/   (@aivn/stage)

这两个包只在构建期用得到:esbuild 把它们打进 lib/,运行时不依赖。 所以装插件的人不需要它们(npm i 只装 dependencies),只有想从源码重建才需要。

npm install         # 需要上面那两个兄弟目录在位
npm run build       # lib/index.js(宿主半边)+ lib/client.js(浏览器半边)
npm run typecheck

lib/ 是受跟踪的产物,改完 src/ 必须重建并一起提交。忘了重建时本地一切正常、别人装上才炸, 所以有一条自查,它同时挂在 pre-commit 钩子上(用同一套构建参数在内存里重打一遍,与仓库里的 lib/ 逐字节比对,不落盘):

node build.mjs --check        # 手动跑
git config core.hooksPath .githooks    # 每个 clone 各做一次,之后提交前自动拦

宿主半边(lib/index.js)与兄弟仓无关,永远硬判;客户端半边(lib/client.js)把 @aivn/stage 打进了包,所以只在兄弟检出干净时硬判,否则只警告——那时不一致多半是别人正在改那只包。

lib/ 是跟踪的构建产物:DSH 直接加载它,所以改完 src/ 必须重建再提交。 npm publish 前会跑 prepublishOnly(重建 + 类型检查),保证发出去的和源码一致。

发新版

npm run release                                            # 重建 + typecheck + bump patch + 打包
node ~/.agents/skills/npm-publish/scripts/publish-webauthn.cjs /tmp/dsh-aivn-<新版>.tgz   # 指纹发布
git push --follow-tags                                     # 版本提交与 tag 上 GitHub

版本语义:修 bug 用 patch、加功能用 minor、破坏性改动用 major。

e2e

dsh-e2e start --wait-ready     # 起一个最小实例(dsh-base + dsh-web-app + 本 worktree)
npm run e2e:stage              # 跑舞台套件「stage」
dsh-e2e stop

实例 home 在 <worktree>/.dsh-e2e-home,不进 git。两个本地前提(都在那个 home 里,不在仓库里):

  • 要有可用的 LLM provider:home 的 settings.yaml 首次启动会被 import 后改名,此后冷启就 没有 provider 了。把你线上 profile 里那条 provider(llm-*)补进 .dsh-e2e-home/profiles/web/cordis.patch.yml,并 export 它用的 API key 环境变量—— 否则 GUI 会被「Add an API key to get started」挡住,会话也发不出消息。
  • 默认模型挑一个你这台机器出得去的:这份模型列表里可能有地区受限的条目(报 User location is not supported 那种)。e2e 不挑具体模型,能通就行。

套件:

  • stage(npm run e2e:stage)——在工作区根落一份临时剧目夹具 → 新建会话并选「剧作家」预设 → 发一句 → 断言「AIVN」tab 挂载、导演工作栏是三格(提示 / 改写 / 重写,没有生图)、台词逐字上屏、 停止点出来、点选项后原文被投给剧作家并在时间线上落成一行回执、剧作家接着写完第二轮。
  • director(npm run e2e:director)——导演工具栏与舞台重投影:导演栏三格、「提示」的两岔是 引导 / 打断、引导进队列又进会话且不落成玩家台词、改写往会话里追加替换标记、舞台收到 reset 帧并整段重投影、剧作家重写这一拍,以及原生「在新对话中分支」出来的新会话里舞台 也从会话面重铺到同一个点。判据取自会话日志的会话面折叠与自挂的 SSE 探针。
  • injection(dsh-e2e run e2e/verify-injection.mjs)——剧作家的上下文注入:从会话日志断言 A 区六段与【状态】都进了系统提示词、内容取自夹具文件而不是「自己去读」,并核对工具面 (两个预设装同一套 AIVN 工具、素材清单不进工具面——它在注入段里)。
  • stagehand(npm run e2e:stagehand)——搭台助手:断言 persona 章节、《当前状态》注入 (文件清单 + 就绪报告)、随包技能库三份都在技能清单里、工具面与剧作家一致 (两个预设同一套)。e2e 实例没配生图与音乐,所以这里同时断言那两个能力的工具 不在工具清单里、persona 里也不提它们(未配后端时不谎报能力)。
  • voice(dsh-e2e run e2e/verify-voice.mjs)——真打 Fish Audio:断言 voice 帧到达、音频能取回 且是真 MP3、浏览器把它解成了 PCM 并真的有音源起播(在页面里给 AudioContext / decodeAudioData / AudioBufferSourceNode.start 挂钩子,不是看帧猜的),以及语音总开关 确实传到了宿主。跑之前要 export DSH_AIVN_TTS_KEYS。
  • style(dsh-e2e run e2e/run.mjs style)——剧目皮肤:先离线对表(白名单 17 键、取值校验、 读闸,以及令牌表与 @aivn/stage/dist/stage.css 的默认值逐键一致),再上真舞台:没有 theme.json 时一个变量都不注且计算样式等于默认皮;写一份皮肤后重挂会话,内联变量与计算样式 跟着变;文件里混进坏键只丢那一项。最后让另一个会话里的搭台助手调用 set_stage_style, 断言已打开的舞台不刷新就换装(跨会话广播)。

截图落在 e2e-artifacts/(不进 git)。