🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

酒馆角色扮演 prophetcrab/DSHTavern

DeepSeek Harness
prophetcrab7 天前有提交已提供中文文档扫描:含敏感能力

用导演编排多角色对话,演绎沉浸式剧情

30.8/100
综合分
2
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/11/3 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add prophetcrab/DSHTavern
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:仅索引
是什么
dsh 原生插件 · skill
装得上吗
本站尚未做安装检查
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 7 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件发起外部网络请求
查看扫描证据(共 135 处,展示前 101 条)
  • 写入本地文件
    lib/index.js:187
    await mkdir(targetDirectory, { recursive: true });
  • 写入本地文件
    lib/index.js:190
    await writeFile(temporary, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
  • 写入本地文件
    lib/index.js:192
    await rename(temporary, target);
  • 删除本地文件
    lib/index.js:195
    await unlink(temporary).catch(() => undefined);
  • 写入本地文件
    lib/index.js:294
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/index.js:1271
    await mkdir(directory, { recursive: true }).catch(() => undefined);
  • 写入本地文件
    lib/index.js:1272
    await writeFile(file, `${JSON.stringify(beat, null, 2)}\n`, 'utf8').catch(() => undefined);
  • 读取本地文件
    lib/index.js:2436
    return await readFile(markerPath(directory), 'utf8');
  • 读取本地文件
    lib/index.js:2734
    return existsSync(join(directory, TAVERN_DIRECTORY, MARKER_FILE)) ? directory : undefined;
  • 读取本地文件
    lib/index.js:2755
    markerText = readFileSync(markerPath(root), 'utf8');
  • 发起网络请求
    lib/client.js:2131
    return readJson$5(await fetch(path, {
  • 发起网络请求
    lib/client.js:2137
    return readJson$5(await fetch(path, {
  • 发起网络请求
    lib/client.js:2156
    availabilityRequest = fetch("/tavern-image/ping", {
  • 发起网络请求
    lib/client.js:3134
    const body = await readJson$4(await fetch(`/tavern/characters?workspaceId=${encodeURIComponent(workspaceId)}`, { headers: { accept: "application/json"
  • 发起网络请求
    lib/client.js:3153
    const body = await readJson$4(await fetch("/tavern/characters", {
  • 发起网络请求
    lib/client.js:3184
    await readJson$4(await fetch("/tavern/characters", {
  • 读取本地文件
    lib/tracker/index.js:310
    const entries = await readdir(storiesRoot, { withFileTypes: true });
  • 读取本地文件
    lib/image/index.js:118
    await readFile(join(workspace.path, '.dshtavern', 'tavern.json'), 'utf8');
  • 读取本地文件
    lib/image/index.js:532
    const info = await stat(file.path);
  • 读取本地文件
    lib/image/index.js:538
    const stream = createReadStream(file.path);
  • 读取本地文件
    lib/character-card.js:230
    names = await readdir(directory);
  • 读取本地文件
    lib/character-card.js:249
    cards.push(parseCharacterCard(await readFile(path, 'utf8'), path));
  • 读取本地文件
    lib/character-card.js:271
    names = readdirSync(directory);
  • 读取本地文件
    lib/character-card.js:285
    cards.push(parseCharacterCard(readFileSync(path, 'utf8'), path));
  • 读取本地文件
    lib/character-card.js:304
    return parseCharacterCard(await readFile(characterCardPath(workspaceDir, id), 'utf8'), characterCardPath(workspaceDir, id));
  • 写入本地文件
    lib/character-card.js:314
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/character-card.js:320
    await writeFile(temporary, text, 'utf8');
  • 写入本地文件
    lib/character-card.js:322
    await rename(temporary, target);
  • 删除本地文件
    lib/character-card.js:325
    await unlink(temporary).catch(() => undefined);
  • 删除本地文件
    lib/character-card.js:378
    await unlink(characterCardPath(workspaceDir, id));
  • 读取本地文件
    lib/worldbook.js:125
    text = await readFile(worldbookPath(workspaceDir), 'utf8');
  • 写入本地文件
    lib/worldbook.js:215
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/worldbook.js:218
    await writeFile(temporary, `${JSON.stringify(book, null, 2)}\n`, 'utf8');
  • 写入本地文件
    lib/worldbook.js:220
    await rename(temporary, target);
  • 删除本地文件
    lib/worldbook.js:223
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/persona.js:113
    body = await readFile(personaPath(workspaceDir), 'utf8');
  • 写入本地文件
    lib/persona.js:153
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/persona.js:156
    await writeFile(temporary, body, 'utf8');
  • 写入本地文件
    lib/persona.js:158
    await rename(temporary, target);
  • 删除本地文件
    lib/persona.js:161
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/opening.js:168
    text = await readFile(join(directory, OPENING_FILE), 'utf8');
  • 写入本地文件
    lib/opening.js:247
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/opening.js:250
    await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, 'utf8');
  • 写入本地文件
    lib/opening.js:252
    await rename(temporary, target);
  • 删除本地文件
    lib/opening.js:255
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/stage.js:102
    text = await readFile(stagePath(workspaceDir, session), 'utf8');
  • 写入本地文件
    lib/stage.js:136
    await mkdir(storyDirectory(workspaceDir, session), { recursive: true });
  • 写入本地文件
    lib/stage.js:138
    await appendFile(stagePath(workspaceDir, session), text, 'utf8');
  • 写入本地文件
    lib/stage.js:171
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/stage.js:175
    await writeFile(temporary, text, 'utf8');
  • 写入本地文件
    lib/stage.js:177
    await rename(temporary, target);
  • 删除本地文件
    lib/stage.js:180
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/stage.js:213
    text = await readFile(worldPath(workspaceDir, session), 'utf8');
  • 写入本地文件
    lib/stage.js:236
    await mkdir(directory, { recursive: true });
  • 删除本地文件
    lib/stage.js:244
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/regenerate.js:138
    names = (await readdir(beatsDirectory)).filter(name => name.endsWith('.json'));
  • 读取本地文件
    lib/regenerate.js:148
    const record = parseBeatRecord(JSON.parse(await readFile(file, 'utf8')));
  • 读取本地文件
    lib/regenerate.js:167
    text = await readFile(join(directory, REGENERATIONS_FILE), 'utf8');
  • 写入本地文件
    lib/regenerate.js:204
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/regenerate.js:207
    await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, 'utf8');
  • 写入本地文件
    lib/regenerate.js:209
    await rename(temporary, target);
  • 删除本地文件
    lib/regenerate.js:212
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/agent-config.js:288
    text = await readFile(agentConfigPath(workspaceDir), 'utf8');
  • 写入本地文件
    lib/agent-config.js:321
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/agent-config.js:324
    await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
  • 写入本地文件
    lib/agent-config.js:326
    await rename(temporary, target);
  • 删除本地文件
    lib/agent-config.js:329
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/prompt-layout.js:476
    const entries = await readdir(join(workspaceDir, 'characters'), { withFileTypes: true });
  • 读取本地文件
    lib/prompt-layout.js:523
    text = await readFile(path, 'utf8');
  • 读取本地文件
    lib/prompt-layout.js:542
    text = readFileSync(path, 'utf8');
  • 写入本地文件
    lib/prompt-layout.js:649
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/prompt-layout.js:657
    await writeFile(temporary, `${JSON.stringify(layout, null, 2)}\n`, 'utf8');
  • 写入本地文件
    lib/prompt-layout.js:659
    await rename(temporary, target);
  • 删除本地文件
    lib/prompt-layout.js:662
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/tracker/summary.js:240
    text = await readFile(trackerConfigPath(workspaceDir), 'utf8');
  • 读取本地文件
    lib/tracker/summary.js:285
    text = await readFile(statePathFor(workspaceDir, session), 'utf8');
  • 读取本地文件
    lib/tracker/summary.js:363
    names = (await readdir(directory)).filter(name => name.endsWith('.json'));
  • 读取本地文件
    lib/tracker/summary.js:372
    const parsed = parseTrackerSummary(JSON.parse(await readFile(join(directory, name), 'utf8')));
  • 写入本地文件
    lib/tracker/summary.js:502
    await mkdir(dirname(target), { recursive: true });
  • 写入本地文件
    lib/tracker/summary.js:504
    await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
  • 写入本地文件
    lib/tracker/summary.js:506
    await rename(temporary, target);
  • 删除本地文件
    lib/tracker/summary.js:509
    await unlink(temporary).catch(() => undefined);
  • 读取本地文件
    lib/image/config.js:296
    text = await readFile(imageConfigPath(workspaceDir), 'utf8');
  • 写入本地文件
    lib/image/config.js:329
    await mkdir(join(workspaceDir, STATE_DIRECTORY), { recursive: true });
  • 写入本地文件
    lib/image/config.js:331
    await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
  • 写入本地文件
    lib/image/config.js:333
    await rename(temporary, target);
  • 删除本地文件
    lib/image/config.js:336
    await unlink(temporary).catch(() => undefined);
  • 写入本地文件
    lib/image/store.js:99
    await writeFile(temporary, data);
  • 写入本地文件
    lib/image/store.js:101
    await rename(temporary, path);
  • 删除本地文件
    lib/image/store.js:104
    await unlink(temporary).catch(() => undefined);
  • 写入本地文件
    lib/image/store.js:149
    await mkdir(directory, { recursive: true });
  • 写入本地文件
    lib/image/store.js:152
    await appendFile(sceneIndexPath(workspaceDir, session), `${JSON.stringify(record)}\n`, 'utf8');
  • 删除本地文件
    lib/image/store.js:155
    await unlink(join(directory, file)).catch(() => undefined);
  • 读取本地文件
    lib/image/store.js:206
    text = await readFile(sceneIndexPath(workspaceDir, session), 'utf8');
  • 读取本地文件
    lib/image/store.js:293
    text = await readFile(portraitsIndexPath(workspaceDir, cardId), 'utf8');
  • 写入本地文件
    lib/image/store.js:321
    await mkdir(portraitsDirectory(workspaceDir, cardId), { recursive: true });
  • 写入本地文件
    lib/image/store.js:355
    await mkdir(directory, { recursive: true });
  • 删除本地文件
    lib/image/store.js:361
    await unlink(join(directory, file)).catch(() => undefined);
  • 发起网络请求
    lib/image/providers/openai-images.js:85
    const post = (body) => fetch(openAiImagesEndpoint(config.baseUrl), {
  • 发起网络请求
    lib/image/providers/jimeng.js:84
    const response = await fetch(url.toString(), {
  • 发起网络请求
    lib/image/providers/http.js:141
    const response = await fetch(url, {
扫描于 2026/11/3(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/dsh-agent@deepseek-ai/dsh-agent-preset@deepseek-ai/dsh-client-ui-conversation@deepseek-ai/dsh-client-ui-layout@deepseek-ai/dsh-client-ui-renderer@deepseek-ai/dsh-client-ui-sidebar@deepseek-ai/dsh-client-ui-slots@deepseek-ai/dsh-client-ui-trajectory@deepseek-ai/dsh-host-webserver@deepseek-ai/dsh-llm@deepseek-ai/dsh-session
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

README

由 DeepSeek 最新模型翻译生成
DSH-Tavern 用 DeepSeek Harness 的插件机制实现 SillyTavern 式角色扮演。 一个酒馆 = 一个带标记的工作区;每个聊天 = 一条会话;每轮 = 一次导演调用 + 每位开口角色各一次独立调用。 导演决定旁白、节奏和谁说话,角色只看到自己的卡和可观察的舞台——两个角色不会互相污染语气,也不会看到导演的私有剧情。 会话里有三个酒馆专属动作:开场回合(新会话在玩家开口前先由模型生成整段开场,作为独立的第一轮写进对话)、重新生成(回退这一幕的舞台与世界状态后重演,旧回复标记为已被取代),以及点击气泡里的角色名直接对这个人说话。 - 架构与开发进度(唯一事实来源,随改动更新):docs/architecture.md - 决策文档(为什么这样设计):docs/decisions/ - 设计文档(准备采用的方案):docs/design/ 组成 插件包含三个可独立启停的组件,以及两个内部预设: | 组件 | 作用 | | dshtavern | 酒馆本体:工作区标记、提示词装配、模型调用拦截与编排、HTTP 路由 | | dshtavern/stats | 用量统计:工作区与会话的 token、上下文占用、缓存命中率 | | dshtavern/tracker | 剧情回顾:后台按计划把剧情重述成摘要,已回顾的轮次在导轨标绿 |
展开完整文档(README 剩余 1 节)
DSH-Tavern

用 DeepSeek Harness 的插件机制实现 SillyTavern 式角色扮演。

一个酒馆 = 一个带标记的工作区;每个聊天 = 一条会话;每轮 = 一次导演调用 + 每位开口角色各一次独立调用。 导演决定旁白、节奏和谁说话,角色只看到自己的卡和可观察的舞台——两个角色不会互相污染语气,也不会看到导演的私有剧情。

会话里有三个酒馆专属动作:开场回合(新会话在玩家开口前先由模型生成整段开场,作为独立的第一轮写进对话)、重新生成(回退这一幕的舞台与世界状态后重演,旧回复标记为已被取代),以及点击气泡里的角色名直接对这个人说话。

- 架构与开发进度(唯一事实来源,随改动更新):docs/architecture.md
- 决策文档(为什么这样设计):docs/decisions/
- 设计文档(准备采用的方案):docs/design/

组成

插件包含三个可独立启停的组件,以及两个内部预设:

| 组件 | 作用 |
| dshtavern | 酒馆本体:工作区标记、提示词装配、模型调用拦截与编排、HTTP 路由 |
| dshtavern/stats | 用量统计:工作区与会话的 token、上下文占用、缓存命中率 |
| dshtavern/tracker | 剧情回顾:后台按计划把剧情重述成摘要,已回顾的轮次在导轨标绿 |
| dshtavern/image | 生图:场景插画与角色立绘,先编辑/细化/批准提示词,再手动调用第三方 API |
| tavern-rp / tavern-novel | 内部 Agent 预设,按工作区模式自动绑定,不出现在全局预设名单 |

后两个组件各自占一个插件行,删掉对应行即整体下线:路由消失、工具箱入口报告不可用,酒馆本体不受影响。内部预设也各占一行(dshtavern/preset-rp / dshtavern/preset-novel),这样插件列表里能分别显示自己的名字与说明。

工具箱

酒馆会话里有一个可拖动的悬浮工具箱图标,包含八个可用面板:

| 面板 | 内容 |
| 提示词编排 | 13 个角色块 / 8 个导演块;拖拽排序、启停、编辑、预览、恢复默认 |
| Agent 编排 | 这一幕 / 上下文 / 模型 / 回复长度四个子页:每幕发言上限、上下文预算(四档一键预设,最大按 1M 窗口标定)、玩家点名优先、导演与角色各自的模型、统一/逐角色回复长度、推理预留 |
| 角色卡 | 角色卡 CRUD,导入/导出单文件 .charc;新会话会先由模型生成一段完整开场(场景 + 角色开口) |
| 世界书 | 工作区级条目编辑与触发预览,整本导入/导出 .world |
| 玩家人设 | 工作区级人设库;角色提示词里的「玩家人设」块可指定具体人设 |
| 用量统计 | 工作区/会话 token、输入输出拆分、上下文占用、缓存命中率 |
| 剧情回顾 | 最新回顾 / 历史记录 / 设置三个子页;见下 |
| 生图 | 场景 / 图库 / 设置三个子页;场景插画与角色立绘,提示词工作台见下 |

生图是可选功能:子插件默认加载,但每个酒馆的 image.json 默认关闭。开启后,场景插画从最新回合的旁白、公开场景和出场角色生成草稿;角色立绘从角色卡的外观/描述生成。流程固定为「编辑草稿 →(可选)LLM 细化 → 批准提示词 → 手动生成」:细化只调用一次模型,不生图;批准由宿主签发 10 分钟有效的一次性 approvalId;只有点「生成图片」才会调用第三方 API。第一版接入 GPT Image(OpenAI 兼容)和即梦 4.0(火山引擎 AK/SK 异步)。密钥保存在 DSH 凭据里,工作区配置不写密钥;行被禁用时工具箱显示灰色「生图(未启用)」。

剧情回顾在后台独立运行,不占用当前回合的模型调用。每次运行都从开场读到目标轮次的完整剧情,重写成一份当前状态摘要:

- 触发方式二选一:每新增 N 轮 或 每 N 分钟(不是两个条件竞速,配了哪个就只看哪个)
- 摘要按三段组织:世界现状、你(玩家)的处境、每个角色各自的进展
- 篇幅按三段各自配置,另有四档一键预设:精简 / 标准 / 详细 / 详尽
- 已回顾的轮次在右侧轮次导轨上标绿;被重新生成覆盖的轮次会撤销标记,并提示摘要已过期

侧栏的工作区列表里,酒馆工作区的文件夹图标会着色,标题后带一个「酒」徽章。

数据布局

全部落在酒馆工作区内:

<tavern>/
.dshtavern/
tavern.json # 标记:schema / mode / title / tavernId / createdAt worldbook.json # 工作区级世界书 agent.json # Agent 编排设置(缺失即用默认值) image.json # 生图设置(provider / 模板 / 开关;不含密钥) state/last-request.json # 被拦截的 loop 请求(只有工作区提示词) stories/<session>/ opening.json # 生成的开场脚本(含当时的 prompt 与模型) stage.jsonl # 舞台:追加写的旁白与台词 world.json # 导演私有:场景 / 事实 / 未揭示剧情 beats/<timestamp>.json # 每次 beat 的完整证据(含回退所需的舞台长度与世界快照) regenerations.json # 被重新生成取代的回合 → 替代回合 images/<id>.png # 场景图原图(jpg / webp 同目录) images/index.jsonl # 场景图元数据:回合 / 最终提示词 / provider tracker/ config.json # 回顾设置(触发方式 / 间隔 / 篇幅预算) state.<session>.json # 该聊天上次回顾到第几轮 summaries/<session>/ # 每次回顾一份,文件名是它覆盖到的轮次 characters/<id>.charc # 角色卡 characters/<id>/portraits/ # 角色立绘 + index.json(activeId) prompts/prompt.json # 角色提示词编排 prompts/director.json # 导演提示词编排

使用

安装并加载后,侧栏出现「酒馆」入口:

1. 新建酒馆:填名称(可再指定绝对目录)→ 创建目录、原子写入标记、注册为 DSH 工作区;
2. 列表显示所有酒馆工作区,按最近刷新时间自动更新。

创建是进入酒馆的唯一入口,面板上也没有退出的按钮:想让一个目录成为酒馆,就在新建表单里把路径指向它;想撤销,删掉该目录下的 .dshtavern/tavern.json 即可——目录本身和已有的聊天记录都不受影响。

创建时必须选择模式(角色扮演 / 小说),创建后不可更改。面板自动刷新(挂载时、每 4 秒、窗口重新获得焦点时、每次操作后)。

点击气泡里的角色名,输入框会预填「对白鹭说:」并把光标停在冒号后——补完这句话发出去,这一轮就由该角色回应。点击本身不会发送任何东西,也不会消耗模型调用。

每幕的提示词可在会话的 轨迹 页查看:每次导演调用(含续幕与格式修复)和每位角色的 system / user / 回复都按顺序记进会话日志,折叠行显示「第 N 轮 · 导演 + 角色名」。它们不是 DSH 的 system/message(编排器绕过 loop 自己发请求),只是插件的 ignorable 记录,不进模型历史。

最新一条回复下方有 重新生成:回退到这一幕开始前的舞台与世界状态,让导演和角色重演一次。新回复作为下一轮追加,旧回复保留并标记「这一条已被第 N 轮重新生成取代」——DSH 的转写只追加,无法原地改写,所以旧回复不会消失。只有最新的回复能重生成。

开发

pnpm install
pnpm build # 宿主 tsc + 浏览器半 tsdown
pnpm test # 434 项,全离线
pnpm typecheck

对运行中的 dsh web 跑接口自测(只读 52 项 / 写入 68 项,可重复执行):

node scripts/selftest-http.mjs --base http://127.0.0.1:3080
node scripts/selftest-write.mjs --base http://127.0.0.1:3080

安装

一条命令(推荐,无需 npm 账号)

npx @deepseek-ai/dsh plugin --profile web add github:prophetcrab/DSHTavern

仓库里提交了编译好的 lib/,所以这条命令不需要 clone、不需要构建、也不需要 npm 账号——pnpm 直接从 GitHub 拉取并安装。国内网络慢时,先给 git 配镜像:

git config --global url."https://gh-proxy.com/https://github.com/".insteadOf "https://github.com/"

配好密钥再启动:

echo "DEEPSEEK_API_KEY=<你的密钥>" >> ~/.dsh/.env
npx @deepseek-ai/dsh web

侧栏出现「酒馆」入口即安装成功。

需要 Node ≥ 24.9 和 pnpm(corepack enable pnpm 即可获得)。密钥按「继承的环境变量 > 启动目录的 .env > ~/.dsh/.env」的顺序读取,后者是用户级、不随仓库走,所以推荐写在那里。可选 DEEPSEEK_BASE_URL 同法配置。

备选:tarball 地址

不想让 pnpm 走 git 时,直接装打包好的文件:

npx @deepseek-ai/dsh plugin --profile web add \
https://raw.githubusercontent.com/prophetcrab/DSHTavern/master/dist/dshtavern-0.3.0.tgz

同样不需要构建。改动插件后重新生成这两者:

node scripts/pack.mjs

它会构建、跑测试、刷新 lib/、写出 dist/dshtavern-<version>.tgz 并清掉旧版本。改了源码要记得跑——lib/ 现在是提交进仓库的,忘了更新,别人装到的还是旧代码。

安装报 ERR_PNPM_EPERM

如果这个 profile 之前用 link: 指向过本地源码目录,再改装 git 版本会失败:

ERR_PNPM_EPERM [importPackage ...\node_modules\dshtavern]
EPERM: operation not permitted, symlink 'D:\...\DSHTavern\node_modules\...'

原因不在包本身:dsh plugin remove 只删清单里的条目,node_modules/dshtavern 那个符号链接会留下,pnpm 于是顺着它去解析本地项目的 peer 依赖,试图把源码树里的链接搬进 profile——Windows 和 macOS 都会拒绝。

删掉残留再装一次即可:

rm ~/.dsh/profiles/web/node_modules/dshtavern # Windows: rmdir /s /q ...
npx @deepseek-ai/dsh plugin --profile web add github:prophetcrab/DSHTavern

node_modules/.pnpm 里没有残留时不必清它。

从源码开发(宿主 checkout)

1. 下载并编译插件
git clone https://github.com/prophetcrab/DSHTavern.git
cd DSHTavern
pnpm install && pnpm build

2. 装进 web 配置(在插件目录里执行)
npx @deepseek-ai/dsh plugin --profile web add .

3. 启动
npx @deepseek-ai/dsh web
在插件目录之外执行第 2 步时,把 . 换成绝对路径,用正斜杠(D:/project/DSHTavern)。Git Bash 会把反斜杠当转义符,写成 D:\project\DSHTavern 会被解析成不存在的 D:projectDSHTavern。

发布到 npm(可选,需要 npm 账号)

上面两条路都不依赖 npm 注册表。如果想让用户用 add dshtavern 这种最短形式,再走这一步:脚本依次构建、跑测试、发布,并把 market/index.json 里的版本号与真实发布时间同步。

npm login --registry https://registry.npmjs.org/
node scripts/publish.mjs

先预演(只构建、测试、打包,不发布):

node scripts/publish.mjs --dry-run

注意两点:

- 本机 npm 默认指向 registry.npmmirror.com,那是只读镜像,不能发布。脚本里把 --registry https://registry.npmjs.org/ 写死了,绕开这个默认值;npm login 也要显式指定同一个源。
- 包名 dshtavern 目前未被占用;一旦发布,版本号不可复用,再发必须升 version。

发布后要把 market/index.json 的 source 从 git 形式换回 { "type": "npm" },否则索引仍指向 git 提交。

插件市场索引(Discover 里点装)

DSH 没有官方市场,Discover 页读的是作者自己托管的 index.json。仓库里的 market/ 就是成品,.github/workflows/pages.yml 会在 market/ 有改动时自动发布到 GitHub Pages:

market/
index.json # 列出 dshtavern 及其版本、来源、兼容范围 icons/tavern.svg # 相对 index.json 解析

启用(只需一次):仓库 Settings → Pages → Source 选 GitHub Actions。之后每次推送 market/ 的改动都会自动重新部署,索引地址是:

https://prophetcrab.github.io/DSHTavern/index.json

用户侧:DSH 里打开「插件 → 实验性插件 → 自定义插件索引」,把这个地址添加为索引源,之后就能在 Discover 里看到插件并点装。

索引格式有三条硬性要求,缺一条整个文件会被拒绝:engines.dsh 必须是合法的 semver range(本插件为 >=0.2.1-rc.2 <0.3.0)、dsh.bundle 必须存在、versions[].publishTimestamp 必须是源发布时间的 Unix 秒。前两条已在 package.json 里声明,第三条在 git 来源下取该提交的 committer 时间。格式可用官方校验器自测:

node -e "
const { parseMarketIndex } = require('<deepseek-harness>/packages/util/plugin-market-index/lib/index.js')
parseMarketIndex(require('./market/index.json'), 'https://prophetcrab.github.io/DSHTavern/index.json')
console.log('valid')
"

索引锁定的是一个具体提交,不是分支——所以用户装到的代码不会在你不知情时改变。发新版本时更新 versions 里那一项的 commit 和 publishTimestamp(取新提交的 git log -1 --format=%ct)。

从源码开发(宿主 checkout)

1. 构建宿主(只需一次)
cd <deepseek-harness checkout>
pnpm install && pnpm build

2. 构建插件
cd <本插件目录>
pnpm install && pnpm build

3. 装进 web 配置(回到宿主目录)
cd <deepseek-harness checkout>
pnpm dsh plugin --profile web add <本插件目录>

4. 启动
pnpm dsh web

第 3 步必须在宿主目录执行:dsh 由宿主仓库提供(pnpm dsh),不在 PATH 上。

dsh plugin 自建的 profile 以 @deepseek-ai/dsh-base 为底,不含 workspace registry。本插件的 ctx.tavernWorkspaces 依赖 web 层,所以自定义 profile 必须用 --from-default-profile web 创建;--profile web 用的是官方预置模板,没有这个问题。

改了源码之后

1. pnpm build 重新生成 lib/;
2. 浏览器半由 client-hmr 按 lib/client.js 的改动自动热替换;
3. 宿主半在 profile HMR 的下一次 reconcile 时替换(用 plugin_manager set_plugin 禁用再启用,或重启 DSH)。

安装到桌面版(Electron)

桌面版的 desktop profile 由应用独占管理,CLI 会拒绝:

error: profile "desktop" is managed exclusively by the Electron application

在 GUI 里安装:侧栏打开「插件」→「添加插件」→ 输入本地绝对路径 → 确认信任后安装。桌面版启用了 HMR,通常无需重启。

已知限制

- 小说模式:能创建工作区并绑定 tavern-novel,但提示词块与编排尚未实现,发消息不会有回复。
- 生图第一版:只接入 GPT Image 与即梦 4.0;ComfyUI、Gemini banana、图生图和自动生图尚未实现。
- 聊天头像:按角色名映射 active 立绘,重名角色第一版不贴头像;头像依赖 DSH 气泡 DOM 结构,上游改结构时会静默不显示。
- 角色卡格式:只认原生 .charc,SillyTavern 的 PNG / V2 JSON 卡要先转换。
- 只能重生成最新一幕:更早的回合要连同其后所有回合一起回退,而只追加的转写表达不了。
- 缓存命中率偏低:导演与角色提示词里,会变化的世界状态和舞台记录紧接在静态前缀之后,缓存只能复用到断点。这是当前提示词结构的直接结果。

安全与权限

插件会读写酒馆工作区、注册仅限本机回环的 HTTP 路由,并在启用图片子插件时用你配置的密钥调用第三方文生图 API。每一项能力的访问边界、外部服务清单、凭据处理方式与失败行为都写在 SECURITY.md,供审阅与人工复审对照。

摘要:

- 文件:只读写酒馆工作区目录;另读一次 DSH_HOME 用于定位新建酒馆的默认位置
- 入站网络:所有路由仅接受 127.0.0.1 / ::1 回环请求,跨站请求返回 403
- 出站网络:核心插件不发出;图片子插件只连你在该酒馆配置的提供商端点
- 凭据:核心插件不读任何密钥;图片子插件经宿主凭据服务解析,密钥只进授权头,不落盘、不进日志
- 子进程:不调用,不执行 shell,不运行任何被安装的包

许可证

MIT。

samples/characters/upstream/ 下的示例角色卡来自 teknium1/character-cards,版权归原作者,不适用本项目的许可证;详见该目录的 README。
预览分享卡片长什么样 ↓prophetcrab/DSHTavern
分享 · DeepSeek Harness Hub
prophetcrab/DSHTavern

用导演编排多角色对话,演绎沉浸式剧情

这是你的仓库?认领条目

回到顶部