DZQJOKER/dsh-plugin-local-model

DZQJOKER/dsh-plugin-local-model

DeepSeek Harness 本地模型插件:在设置里管理 GGUF 模型,首条对话自动拉起 llama.cpp,空闲 5 分钟自动卸载释放显存。

模型与账号接入

dsh-plugin-local-model

给 DeepSeek Harness(dsh) 用的本地模型插件:在设置里管理本地 GGUF 模型, 第一条对话自动拉起 llama.cpp 载入模型,连续 5 分钟无交互自动卸载并释放显存; 设置页顶部还有一块参数预设,把整套参数存成带名字的条目,一键切换。

模型和 llama 工具由用户自己下载,放进插件规定的目录即可 —— 插件不联网拉模型、不碰工作区文件、 除本机回环地址外不监听任何端口。

更新日志

  • 0.7.0 — ① 修掉「装上视觉投影文件后一直报 400 Failed to load image or audio file」。根因不在插件、也不在模型:llama.cpp 内置的图像解码器只认 png / jpeg / gif / bmp 这些老格式,不认识 WebP;遇到 WebP 它会去起外部的 ffprobe 探测、ffmpeg 转码,而这两个是从 PATH 里找的(这个构建砍掉了 --ffmpeg-path,只剩 PATH 一条路)。机器上没装 ffmpeg 时,服务端会打 probe: failed to launch ffprobe / failed to decode webp buffer,客户端拿到 400 —— 而同一批里的 JPEG 却能正常识别(dsh 转码时两种格式都会产出,实测 .dsh/attachments/v1/request-images/ 下两种都有),所以表现是「时好时坏」,极易被当成插件或模型坏了。现在:插件会自动找到 ffmpeg 并把它的目录前置进 llama-server 子进程的 PATH(不必改系统 PATH、也不必为此重启 dsh),找不到且已开视觉时会打出一条说清「哪个格式会失败、为什么只有它、怎么修」的警告。装法:winget install Gyan.FFmpeg。 ② MTP 与视觉投影从「互斥」改为「可共存」(新增设置项 mtpWithVision,默认开启)。原规则来自上游 llama.cpp(两者同时下发会加载失败),但 kvmem 分支的 llama-kvmem-server 实测支持:同时给 --mmproj 与 --spec-type draft-mtp 时 clip_model_loader: has vision encoder 与 creating MTP draft context 会同时出现、服务正常起来、看图对话也能识别(2026-09-21 本机验证)。换回官方 llama.cpp 的用户把这个开关关掉即恢复旧的互斥行为(MTP 生效、--mmproj 被忽略并给出说明)。 ③ 设置页顶部新增「本次启动参数」面板:把这次真正下发给 llama-server 的完整命令行逐项一行列出来(不是设置页里填的那一份 —— 两者之间隔着门控跳过、构建默认值与自动收敛),配一张参数摘要(含派生的「GPU KV 合计 = 工作集 + 解码预留」)、一条「复制命令行」按钮、以及被跳过的选项与识别表没覆盖到的选项(后者过去只躺在日志里)。刷新跟随运行状态轮询,换模型/改参数后自动更新。

  • 0.6.0 — ① 补齐 kvmem 分支(kvmem/kvmem-llama.cpp)的全部缺失启动参数,共 31 项:KVMem 家族 18 项(--kvmem / --kvmem-budget / --kvmem-gen-reserve / --kvmem-block-tokens / --kvmem-sink-tokens / --kvmem-recent-tokens / --kvmem-method / --kvmem-query-last / --kvmem-query-max-tokens / --kvmem-query-replay / --kvmem-query-policy / --kvmem-mtp-state / --kvmem-gpu-ratio / --kvmem-cpu-gb / --kvmem-nvme-gb / --kvmem-nvme-dir / --kvmem-harvest-v / --kvmem-raw-k-nvme)、外加 -n/--n-predict、-lm/--load-mode、--kv-dtype、--spec-kv-dtype、--spec-draft-n-max、--spec-draft-p-min、--frequency-penalty、--mmproj-offload、--chat-template-file、--chat-template-kwargs、--reasoning-effort、--reasoning-budget-message。默认值一律是「不下发」(数字项用 -1 当哨兵、字符串用空串),所以装官方 llama.cpp 的部署行为一字不变;kvmem 专有项走严格门控,探测不到就整体跳过并在日志里说明。 ② 新增「输出上限溢出保护」(guardContextOverflow,默认开启)。上游 llama.cpp 遇到 prompt + max_tokens > n_ctx 只打一条 warning 再自行收敛,而 kvmem 那个独立 server 是硬拒绝:HTTP 400 {"error":"prompt + max_tokens exceeds n_ctx"} —— 报错里既没有 prompt 长度也没有 n_ctx,用户只能乱试。现在代理会先把「必然失败」(max_tokens >= n_ctx,任何非空提示词都放不下)的上限预防性压到 n_ctx − 1024,再对服务端明确拒绝的请求逐级减半重试(只缓冲 400,其余状态码照旧流式直通,正常对话零额外开销);实在装不下时把原文换成一句带上真实 n_ctx 与最可能原因的中文说明。 ③ 新增加载期配置一致性提醒:maxTokens 不小于 ctxSize(本轮线上故障的根因)、超过一半、或 --kvmem-gen-reserve 小于单次输出上限时,加载日志里直接点明后果与建议值。 ④ 修掉一处静默缺陷:被跳过选项的汇总提示原先在采样参数之后就结算了,导致后面新增的每一项(--kvmem-* / -n / -lm …)被跳过后都不会出现在日志里。 ⑤ 新增「KVMem 分块缓存」与「多 Token 预测(MTP)细节」两个设置分组。

  • 0.5.3 — 修掉「换了一个 llama 分支,插件就起不来了」。用户把可执行文件换成那个 kvmem-v0.16.0-rc2 的 llama-kvmem-server.exe(一个独立的 OpenAI 兼容 server, 选项表只是 llama.cpp 的真子集),加载直接失败: unknown flag: --alias + usage + exit 1。根因是插件固定下发的一组选项里, 有一部分是这个分支根本没有的 —— 探测机制此前只门控了「新版本才有的」那 4 类选项 (--kv-unified / --kv-stream-stage-mib / --image-*-tokens / --reasoning-budget), 其余的(--alias、-t、--threads-batch、-ub、--repeat-last-n、--no-mmap、 --mlock、--api-key、采样参数那一组)一律照发。而这类分支对未知选项不是忽略,是直接退出 1, 且一次只报一个 —— 所以「只修 --alias」是不够的,下一个会是 -ub。 修法:把「只下发构建承认的选项」这条既有契约扩展到全部选项,并分成两档语义: ① 只有新构建/特定分支才有的 → 严格门控(探测失败也不发,与原来一致); ② 其余 → 宽松门控(探测失败照旧全量下发,行为与历史完全一致;只有构建明确不公开某一项时才跳过那一项)。 被跳过的一律写进日志(已跳过(不影响加载):--alias、-ub、…),绝不静默。 另修一处同源故障:--seed 的默认值是 -1(= 随机),而该分支按 uint32 校验, 收到 -1 直接 invalid --seed: seed out of range [0, 4294967295] 退出 —— 现在负数一律不下发该参数(与「随机」等价:llama.cpp 的默认值本来就是 -1), 一条规则同时满足标准构建(语义不变)与取值范围收窄的构建。 实机验收(用用户真实配置,模型换哨兵路径): ① kvmem 构建 —— 63 个选项被识别,命令行里再无未知项,实测参数全部通过; ② llama.mtp加速优化版(405 个选项)—— 命令行与修复前逐字一致(只少了语义等价的 --seed -1), 参数全部通过。也就是说这一版对标准/近上游构建是零行为变化。 新增回归用例:scripts/selftest.mjs 里按 kvmem 的 --help 原文建了一组断言(含「下发出去的选项必须全被公开」), scripts/verify-llama.mjs 补上 unknown flag / invalid --xxx: 两类判据(此前它会把这句漏判成「没有参数错误」)。

  • 0.5.2 — 修掉设置页透明重叠。0.5.1 的诊断是错的 —— 那三处几何问题确实存在、也确实修好了, 但它们不是用户看到「字压字」的原因。真正根因是插件引用了 DSH 里根本不存在的 CSS 变量: 插件整套用的是 --color-background-primary / --color-border-tertiary / --color-text-primary 这一组(Claude / Cursor 风格的通用 token),而宿主 DSH 只提供一套 --dsw-* (全量扫 app.asar 的 21810 个文件,上述 --color-* 的定义数都是 0)。 var(--x, fallback) 在变量不存在时静默退回 fallback,于是: ① card 与底部保存条的 fallback 写的是 'transparent' → 它们真的是透明的, 下层「运行状态」卡片的文字(当前模型 / 进程 / 入口 / 模型总数 / 推理档位 / 多 Token 预测 / 视觉投影) 直接透上来;② 置顶预设条的 fallback 是 '#fff' → 浅色主题下白底等于面板白底, 眼睛看仍是「透明」;③ 输入框 / 下拉框的 --color-background-secondary 也落空到 transparent, 在卡片上是一块「看不见底」的区域;④ 深色主题下 --color-text-primary → #111, 主按钮变成黑底黑字。修复:颜色体系整体迁到 --dsw-* (面板底 --dsw-alias-bg-layer-2,与宿主 .MI-_Aa_panel 同色;边框 --dsw-alias-border-l1..l4; 文字 --dsw-alias-label-*;语义色 --dsw-alias-state-*),并给所有「会挡住下层内容」的容器 补上不透明背景。另修两处连带问题:⑤ 置顶条与保存条的横向负 margin —— .options 左右各有 24px 内边距,sticky 元素默认只盖内容区宽度,两侧那 24px 缝里滚过去的内容会直接露出来 (旧版保存条 padding: '10px 0' 时连上下都有 10px 缝,「当前模型」就是从这条缝里透出来的); ⑥ 状态徽章底色改用 color-mix 从前景色兑出,深色主题下不再出现「浅底浅字」。 client/src/layoutCheck.js 新增变量白名单与透明度两类断言, 并加了一条反向验证(拿有 bug 的坏样本必须报错)—— 因为这类探测最大的风险是 「规则永远不触发」的假阴性,那比没有规则更危险。

  • 0.5.1 — 修掉设置页元素重叠的几何成因(三处,都是「在某些屏幕宽度下才现形」的布局问题)。 根因是 Host 设置面板实测只有 564px 可用宽度(面板 800 − 左侧导航 188 − 内边距 48), 而客户端把内容区写成了 maxWidth: 720,比可用宽度还宽 156px: ① 字段行是 grid 三列,第二列写的是裸 1fr —— grid 的自动最小尺寸默认 auto, 它拒绝被压到输入框 min-content 以下,于是可用宽度不足时第二列被挤到下一行、 「默认」按钮回到右侧,两行内容在视觉上叠在一起(≤800px 的面板必现,即任何笔记本); ② 置顶预设条的 position: sticky 包含块是滚动容器 .options 的内边距盒, top:0 正好落在面板 32px 圆角上,滚起来会切掉卡片上沿、压住面板标题与关闭按钮; ③ .options 的上内边距为 0 而 sticky 不认领它,卡片上方留下一条能滚动的透明缝, 下层卡片从缝里透出来。现在:内容区改为 maxWidth: 100%;三列全部 minmax(0, …) 并把控件都补上 minWidth: 0(字段行最小需求降到约 204px,480px 宽的窗口都富余); 置顶条用实色背景 + 顶部内边距 + isolation: isolate 把自己封住,不再和面板抢层级。 另修:超长预设名会让 chip 把 ✎ / × 挤出卡片(现封顶 240px 并省略), 元信息网格的 minmax(220px, 1fr) 硬下限在窄面板下整块溢出(现 minmax(0, 220px))。 只改样式,不动任何组件结构、路由与交互;新增 client/src/layoutCheck.js 把这几条 几何约束做成断言,client-check 会在常见分辨率下逐个验算「设置项一行排得下」。 ⚠️ 透明背景问题不在这一版的修复范围内,见 0.5.2。

  • 0.5.0 — 新增参数预设:把整套加载/推理参数(含选中的模型与视觉投影)存成带名字的条目, 可存多组、可重命名、可覆盖、可删除,点一下即切换。预设条固定在设置页顶部, 滚到参数区也能直接切。预设只收「参数」不收「环境」—— 端口、监听地址、模型/运行时目录、 密钥、日志级别这 9 项不进预设,切换预设不会动它们(否则切个参数预设会把端口也换掉)。 存储在 <DSH_HOME>/local-model/state/presets.json(与 config.json 同目录,独立成文件, 不污染既有配置)。新增功能不动既有行为:所有原设置项、按钮、请求改写、加载/卸载逻辑一字未改, 预设的应用路径就是「保存设置」那条路径(写入用户层 → 按新参数卸载 → 下次对话重载)。

  • 0.4.2 — 修掉「推理档位拨了会 500」。0.4.1 让 dsh 把档位写进 chat_template_kwargs 之后暴露了两个新问题:① 模型模板对不认识的档位是直接 raise_exception(实测这个模型只认 xhigh/medium/low),而插件把面板档位原样透传、dsh 还会把 off 发成布尔 false —— 一次对话直接 500;② 插件仍会在某些情形下覆盖请求自己的档位选择(开关关闭时把 High 也压成不思考)。现在:加载后读模型模板解析出它支持的档位表(日志与状态卡都会显示),面板档位按表重映射(high → xhigh);映射不出来就不发这个字段;false/null/true 等形态一律归一化;开/关以请求为准,插件开关只在请求什么都没说时生效。

  • 0.4.1 — 修掉「dsh 的推理等级滑杆形同虚设」。两处成因,都修了:① 插件注册进 dsh 的路由 profile 没有声明这是推理模型(缺 reasoningEfforts),pi-ai 因此什么思考参数都不发,滑杆只是个摆设;现在会声明 compat.thinkingFormat = chat-template 与七档映射,让 dsh 把档位写进 chat_template_kwargs(llama.cpp 唯一认的通道)。② 插件的「启用思考」开关无条件把 enable_thinking 改写成 true,把 dsh 选的 Off 也顶掉了 —— 现在开关开启时只当默认值:请求里已经显式写了 enable_thinking 或带了档位就原样放行;关闭时仍是硬覆盖。另外代理会把顶层的 reasoning_effort 下沉进 chat_template_kwargs —— 顶层字段会被 llama-server 静默丢掉(无报错、无日志),这是社区踩过的坑。

  • 0.4.0 — ① 新增 13 项设置:KV 缓存策略 2 项(kvUnified、kvStreamStageMib,其中流式暂存是特定 llama.cpp 分支的私有参数)、采样参数 8 项(temp / topK / topP / minP / presencePenalty / repeatPenalty / repeatLastN / seed)、多模态与推理预算 3 项(imageMinTokens / imageMaxTokens / reasoningBudget)。这些参数现在会在每次加载时显式下发并覆盖 llama.cpp 自身的默认值(temp 0.8→0.75、top-k 40→20、min-p 0.05→0、repeat-penalty 1.1→1.0),设置页里逐项注明了差异。② 删除「接入 dsh」整组设置(routeName / modelAlias / routeModelId / contextWindow / registerRoute / exposeTool / allowModelControl):这 7 项改为 configResolve.ts 里的常量,取值沿用原默认值,行为与默认安装完全一致但从此不可配置。③ maxTokens(单次最大输出 tokens)保留,从「接入 dsh」挪进「推理参数」。④ 顺带修掉一处会静默毁掉小数设置的 bug:clamp() 内部有四舍五入,temp 0.75 会被它变成 1;小数项改用新的 clampFloat()。

  • 0.3.1 — 新增「多 Token 预测(MTP)」开关(mtp,默认关闭)。开启后加载时下发 --spec-type draft-mtp,用模型自带的预测头做投机解码,本地生成速度通常提升 1.2~2 倍。开启 MTP 会自动禁用视觉投影文件(--mmproj) —— 两者在 llama.cpp 里不能共存,强行一起下发会导致加载失败;这条互斥规则写在参数拼装层(src/llama/args.ts),只要 mtp 为真 --mmproj 就不可能漏下去,界面上对应的下拉框会立即置灰并在状态卡里说明原因。关掉 MTP 后用户选的 mmprojFile 不会被清空,只是暂时不生效。默认关闭,因此升级后老部署的行为一字不变。

  • 0.3.0 — 两项推理侧能力:① 推理参数新增「启用思考」「保留历史 think」两个开关,按 chat_template_kwargs(enable_thinking / preserve_thinking)在每次请求的请求体上下发,关闭「保留历史 think」时还会顺手剥掉历史 assistant 消息里的 reasoning_content / think 文本,让多轮上下文中不再堆积思考内容;② 模型与目录新增「视觉投影文件」选择项,可直接挑选 mmproj(加载时作为 --mmproj 下发),留空即保持原有的「同目录自动关联」行为。这两项开关走请求体而不是 llama-server 启动参数:旧构建只会忽略它,绝不会影响模型加载,也不必为切一次开关重启模型。

  • 0.7.1 — 取消版本闸门:删掉 engines.dsh,@deepseek-ai/dsh-tools 的 peer 范围由显式预发布分支回归 "*"。原先逐 minor 补 || ^X.Y.Z-0 分支的做法在 harness 出新预发布时必然滞后,导致市场误判「与当前 DSH 不兼容」并弹崩溃警告。插件功能一字不变,兼容性交给宿主自行判定。

  • 0.2.3 — 发布准备:补 repository / homepage / engines.dsh;peerDependencies 从 "*" 改为显式的预发布分支(原来的 * 会静默匹配不到 harness 的 -rc.x 构建);loader entry id 由裸 local-model 改为 dzqjoker-local-model 避免与其他插件撞车;去掉 private 与 prepare(产物随仓库发布,安装时不再需要构建);补 LICENSE、.gitignore、.gitattributes;PLUGIN_VERSION 与 package.json 对齐。另修 npm run verify:它过去不读 DSH Desktop 的 activeHome,数据目录被搬走后会漏扫真正在用的 profile,报出「未安装本插件」的假结论。

  • 0.2.2 — 修复 CUDA OOM:新增 gpuLayersMode(auto / all / custom)。默认 auto 会下发 -ngl auto,让 llama.cpp 的 --fit 按可用显存自适应卸载 —— 之前默认把 -ngl 钉成 -1 跳过了这层保护,模型放不下时从「少放几层」变成「直接 OOM」。同时把失败日志翻译成可读诊断(fit-blocked-by-pinned-layers / cuda-oom / 形状不匹配 / 模型缺失 等十几种模式)。

  • 0.2.1 — 修复 --flash-attn 形状不匹配:老构建按裸 flag 发,参数解析器把下一个 token 当成它的值吃掉;新增能力探测与三态配置(auto / on / off),auto 永远不下发最安全。

  • 0.2.0 — 修复 dsh.bundle 声明形状 + 设置侧栏面板;新增浏览器半侧 bundle + 同源数据桥。

  • 0.1.0 — 初版:设置项 / 拉起 / 空闲卸载 / 路径约定 / 接入 dsh 路由。


1. 需求 → 实现对照

需求落点
① 设置里新增独立「本地模型」配置项,可选模型、可填启动参数设置侧栏一级页面:src/config.ts 的 schemastery Config(唯一真源)+ src/webBridge.ts(数据面)+ client/(浏览器半侧面板)。字段说明见 第 4 节
② 用户自行下载 llama 工具与模型,存入插件规定目录src/paths.ts 定义目录约定并在首次运行时写入放置说明;scripts/fetch-llama.mjs 可选一键下载
③ 选定模型后首条对话自动拉起并载入src/proxy.ts(常驻轻量入口)+ src/lifecycle.ts 的 ensureReady() 单飞加载
④ 连续 5 分钟无交互自动卸载释放资源idleUnloadMinutes(默认 5);判定规则抽成纯函数 shouldUnload(),src/lifecycle.ts
⑤ 参数预设:存多组带名字的参数、固定在页面顶部、一键应用src/presets.ts(独立落盘 state/presets.json + 作用域定义)+ webBridge.ts 的 5 条 /presets/* 路由 + 浏览器侧 client/src/presets.jsx(顶部预设条)。见 第 4 节

「不占资源」是硬指标:没有对话时磁盘上只有一个常驻 HTTP 代理进程(不加载模型、不占显存), llama-server 只在第一个请求进来时才被 spawn。这一条有端到端测试兜底(见第 9 节)。


2. 安装

# 1) 装进 profile —— 这一步同时写入依赖 + 把它登记进 dsh.profile.bundles
dsh plugin --profile desktop add github:DZQJOKER/dsh-plugin-local-model
#    本地开发时改成本地路径:
#    dsh plugin --profile desktop add D:/path/to/dsh-plugin-local-model

# 2) 重启 dsh —— bundle 组合在启动时固定,装/卸插件都必须重启(刷浏览器不生效)

装完打开 设置 → 本地模型,就能看到独立的配置组。

从 GitHub 安装不需要在用户机器上构建

本仓库已包含预构建产物 —— lib/(宿主半侧)与 client/client.js(浏览器半侧)。 包里刻意没有 prepare 脚本:pnpm 对 git 依赖默认拒绝执行构建脚本,会以 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED(needs to execute build scripts but is not in the "allowBuilds" allowlist)在物化之前就中止安装。产物入库 + 去掉 prepare,这条路才是通的。

代价是改了 src/ 或 client/src/ 之后必须重新 npm run build 再提交, 否则用户装到的是上一次的产物。改完用 npm test 跑一遍(它会先 build 再自查)。

装完先自查一次

npm run verify

它会检查两件事:本包是不是一个合法的 dsh bundle,以及它在哪些 profile 里装了却没生效。 后者正是插件管理页显示「已安装,未生效」的成因,输出长这样:

A. bundle 清单
  ✓ dsh.bundle.patch = ./cordis.patch.yml
  ✓ patch 的 name 是包名:dsh-plugin-local-model
B. profile 挂载状态
  ✗ desktop → 已安装为依赖,但不在 dsh.profile.bundles 里(这就是「已安装,未生效」)

为什么必须声明 dsh.bundle

dsh 的安装机制建立在两个 manifest 概念上,都写在 package.json 的 dsh 字段里:

概念是什么声明方式
bundle一个附带配置层的 npm 包,说明「这个包贡献什么」"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
client浏览器半侧(设置界面、面板、卡片)"dsh": { "client": { "inject": [...], "platform": "web" } } + exports["./client"]
profile$DSH_HOME/profiles/ 下的目录,说明「这套配置由哪些 bundle 按什么顺序组成」profile 自己的 dsh.profile.bundles

没有 dsh.bundle 的包照样能装上,但只会作为普通依赖存在,dsh 不会激活它贡献的任何层。 这个设计是给「被别的插件包 import、而不是供用户直接启用」的库用的;对一个要直接启用的插件来说, 漏掉这个字段就等于白装。声明形状写错(比如写成字符串 "bundle": "包名")和没写是同一个后果。

宿主侧的 Config schema 只负责配置的读写,不会自己长出界面。 dsh 设置侧栏的每一项都是 一个客户端 UI 插件贡献的,所以要让「本地模型」出现在设置里,必须另外提供浏览器半侧 (dsh.client + lazy-CJS factory 形式的 bundle)。官方 dsh-client-ui-settings-plugins 的文档把这句 写得很直白:

卡片仍然需要一份浏览器 bundle:浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 dsh.client 包,而产出它的 clientBundle 预设位于 packages/client/tsdown.client.ts, 并非已发布的包,因此本仓库之外的插件得自行复刻该构建。

本插件的复刻方式是 scripts/build-client.mjs(esbuild 打包 + 手工套上 window.__ModuleLoader__.load 外壳),并由 scripts/client-check.mjs 用假 loader 真跑一遍来保证格式没跑偏。

配置层的叠加顺序是:profile 里各 bundle 的 patch(按声明顺序)→ profile 自己的 cordis.patch.yml → $DSH_HOME/cordis.patch.yml → 命令行 --patch。后一层覆盖前一层,且是整块替换 config 而不是深合并。

3. 放模型和 llama 工具

首次运行插件会建好目录并放入说明文件:

$DSH_HOME/local-model/            # Windows 默认 C:\Users\<你>\.dsh\local-model
├── models/                       # ← 把 GGUF 放这里(可建子目录)
│   └── PUT_GGUF_MODELS_HERE.txt
├── runtime/                      # ← 把 llama.cpp 解压到这里(可放在一层子目录里)
│   └── PUT_LLAMA_RUNTIME_HERE.txt
└── state/                        # 插件自管:pid、日志、设置与预设,不用管
    ├── config.json               # 设置页保存的用户层配置
    └── presets.json              # 参数预设(0.5.0 起;一组都没存过时这个文件不存在)

llama 工具:去 https://github.com/ggml-org/llama.cpp/releases 下载对应平台的包,解压到 runtime/ (NVIDIA 选 win-cuda-x64,无独显选 win-cpu-x64,macOS 选 macos-arm64)。 也可以让脚本代劳:

node scripts/fetch-llama.mjs                 # 自动判断平台与后端
node scripts/fetch-llama.mjs --variant=cuda  # 指定后端:cuda / vulkan / hip / cpu
node scripts/fetch-llama.mjs --dry-run       # 只预览要下载哪个包

模型:下载 .gguf 放进 models/。插件支持三种布局,全自动识别:

  • 单文件:Qwen3-8B-Q4_K_M.gguf
  • 分片:xxx-00001-of-00003.gguf ×3 —— 会自动归并成一个模型,缺片会明确提示而不是加载半截模型
  • 视觉:同目录放 mmproj-*.gguf —— 只在能确定归属时自动关联(宁可漏配,不可错配); 目录里有多个模型/多个投影文件而自动关联不出来时,去 设置 → 本地模型 → 视觉投影文件 手动选一个即可。

放好后回 设置 → 本地模型 点「重新扫描」,在下拉框里选模型。

4. 设置页与设置项

面板在哪

重启 dsh 后,打开 设置,侧栏会出现一级条目 「本地模型」(排在「通用设置 / 模型」之后)。 面板里依次是:

  • 参数预设(0.5.0 起,置顶那块):点名字即应用,✎ 改名,× 删除,输入框 + 「保存为预设」新建, 应用过之后还能「用当前参数更新「X」」。见 下面单独一节。
  • 运行状态:状态徽标(待机 / 加载中 / 已就绪 / 已卸载 / 失败)、当前模型、进程 pid、入口地址、 预计自动卸载时间、最近一次错误;配「重新扫描 / 立即加载 / 卸载 / 刷新状态」按钮。
  • 模型下拉框:列出模型目录里扫到的全部 GGUF,带量化、参数量、体积、是否分片完整、是否含视觉投影。 分片不完整的会置灰并在文案里说明。
  • 六个参数分组:模型与目录 / 服务与端口 / 推理参数 / 采样与 KV 缓存 / 加载与卸载 / 诊断与高级。 每个字段都带中文说明,文案与默认值直接来自 src/config.ts 的 schema,字段被改过的会标「已覆盖」, 旁边有「默认」按钮可单独恢复。
  • 目录约定:模型目录、运行时目录、用户配置文件三个绝对路径,照着放文件即可。
  • 底部:保存 / 放弃修改 / 恢复默认;未保存项数会实时标出。

面板是数据驱动的:字段列表、类型、默认值、说明全部由宿主把 schema 序列化后送过来 (Config.toJSON() → src/schemaForm.ts),因此往 config.ts 加一个字段,界面上会自动出现。 两个例外是需要下拉框的字段(selectedModel 选模型、mmprojFile 选视觉投影文件)—— 选项来自扫描结果、schema 表达不了,所以由客户端特判渲染,其余字段一律自动出现。

布局约束(0.5.1 起,改样式前必读)

设置面板可用宽度只有 564px,这是量出来的、不是估的:

宿主 .panel    width: 800px(max-width: calc(100vw - 48px))
左导航 .nav    width: 188px(flex:none)
.options        padding: 0 24px 24px
                ────────────────────────────────
内容区          800 − 188 − 48 = 564px

因此客户端样式有两条硬约束,scripts/client-check.mjs 会逐条断言(client/src/layoutCheck.js 里是规则本体,新增样式遇到同类问题时往里加一条):

  1. 不写死超过可用宽度的 maxWidth。 内容区用 maxWidth: '100%'。
  2. 凡是 flex / grid 里的容器,一律 minWidth: 0,列宽用 minmax(0, …)。 grid 的自动最小尺寸默认是 auto(= 不小于内容最小宽度),裸 1fr 于是拒绝压缩 —— 可用宽度不足时会把格子挤到下一行,和相邻元素在视觉上重叠。这条踩过一次: 字段行 minmax(140px, 190px) 1fr auto 的最小需求约 420px,面板 ≤800px 时必现。

置顶那条 position: sticky 另有三点必须一起满足(原因写在 styles.js 的 cardPinned 注释里): 不透明背景(遮住下层内容)、横向盖满 .options 的 24px 内边距(两侧不留缝)、 isolation: isolate(自建层叠上下文,不与面板标题栏抢层级)。

颜色变量:只能用 --dsw-*(0.5.2 起,血泪条款)

DSH 里不存在任何 --color-* 变量。 宿主 @deepseek-ai/dsh-client-ui-theme 只定义一套 --dsw-*:

--dsw-alias-bg-base / bg-layer-1|2|3 / bg-overlay / bg-mask-1
--dsw-alias-border-l1|l2|l3|l4
--dsw-alias-label-primary|secondary|tertiary|dimmed|caption
--dsw-alias-button-primary-fill|hover / button-ghost-active-fill|border
--dsw-alias-interactive-bg-hover
--dsw-alias-state-error|success|warn-primary / -secondary / -tertiary
--dsw-static-neutral-bluish-{00,50,60,75,100,…,1000} 等静态色板

写完 var(--color-background-primary, 'transparent') 这种代码时,不会有任何报错、任何警告 —— 变量不存在,var() 直接退回 fallback,而 fallback 是手写的、很容易写成 transparent。 症状就是「元素是透明的、下层文字透上来」,然后你会去怀疑 z-index、position、 层叠上下文……全都不对,因为根本没东西被画出来。

三条规则(client-check.mjs 会拦):

  1. 只用 --dsw-* / --font-* / --dsh-*,其余前缀一律视为宿主不提供。
  2. 会挡住下层内容的容器(card / cardPinned / footer)背景必须不透明。 面板底统一用 --dsw-alias-bg-layer-2 —— 宿主 .MI-_Aa_panel 自己就是这个值, 两边同色才能让 sticky 元素「融进」面板、看不出接缝。
  3. sticky 条要盖满 .options 的横向内边距:marginLeft/Right: -24 + paddingLeft/Right: 24。 否则卡片两侧各留 24px 缝,滚动时下层内容会从缝里穿过去。

排查这类问题的通用手法:从 app.asar 全量扫变量定义(find.mjs / pick.mjs 在 D:\日常工作区\_dev-tools\dsh-asar-tools\)。只要某变量在 21810 个文件里出现次数为 0, 它就是不存在,代码里每一次 var() 都在走 fallback。

参数预设(0.5.0 新增)

设置页最上面那一块,用 position: sticky 钉在顶部:下面几十项参数是用来调的, 切换整套参数时不该还让你滚回页面顶端去找按钮。

操作怎么做
新建输入名字(回车或点「保存为预设」)。名字最长 40 字,重名会被拒绝(不区分大小写),最多 100 组
应用点预设名。按新参数卸载模型,下次对话重新加载 —— 与「保存设置」同一条路径、同一个后果
重命名 / 删除预设上的 ✎ / ×。删一组参数不影响当前生效的设置
更新某一组应用过之后,会出现「用当前参数更新「X」」——把界面上这套参数写回它,名字不变
看差异每个预设上标着「N 项不同」,与当前生效配置完全一致的那个标「当前生效」并高亮

哪些设置会进预设,哪些不会:

字段为什么
✅ 进预设(41 项)当前模型、视觉投影文件、MTP、上下文长度、GPU 层数、线程/批大小、Flash Attention、KV 精度与策略、8 项采样参数、图像 token 预算、推理预算、三个思考开关、Jinja/模板、mmap/mlock、附加参数、环境变量、空闲卸载与超时重试、单次最大输出这些就是「一套参数」的内容。像「看图」这种预设,本来就该连同模型一起切过去
❌ 不进预设(9 项)enabled、modelsDir、runtimeDir、llamaServerPath、host、port、llamaPort、apiKey、logLevel这些属于这台机器而不是这套参数:端口和别的服务是否占用有关、路径是部署环境、密钥不该在预设文件里再存一份明文、日志级别是排查时的临时开关。切个参数预设却把端口换掉,只会是事故

作用域由 src/presets.ts 的 PRESET_EXCLUDED_KEYS 唯一定义,界面上的说明文案也来自同一处 (宿主把 keys / excluded 一起送进 /state),不存在两边各写一份而漂移的可能。

两条交互规则值得知道:

  1. 保存的是界面上当前这套参数,含还没保存的修改 —— 界面会写明「含 N 项还没保存的修改」。 数字框被清空时按「未设置」处理(回落成已保存的值),不会写成 0;
  2. 应用预设会丢弃未保存的草稿,所以有草稿时会先弹一次确认;应用成功后草稿清空 —— 否则界面上会留下一堆「未保存」的假象。

预设落在 <DSH_HOME>/local-model/state/presets.json,与 config.json 同目录但独立成文件: 一份配置 + 若干套可切换的参数,互不干扰。文件损坏、内容离谱(缺名字、缺 id、重复 id、一项参数都没有) 都只会降级成「没有预设」并在面板上说明,绝不影响模型加载这条主线 —— 它只是便利功能。

设置的三个层级

沿用 dsh 自己的三层语义,用户层落在插件目录里:

schema 默认值  →  组合层(cordis.patch.yml / 部署配置)  →  <DSH_HOME>/local-model/state/config.json

面板保存写入的是最上层那个文件;「恢复默认」清空它,于是回落到组合层 + schema 默认值。 浏览器发来的 JSON 只按 src/configStore.ts 里的类型表做强制转换,表里没有的键一律丢弃 —— 界面写不进一条会污染 llama-server 命令行的脏数据。

「应用预设」写的是同一个文件、过的是同一道闸门:预设值先按作用域过滤(见上一节), 再走 ConfigStore.update() —— 于是「点预设」与「点保存」在落盘、卸载、提示三件事上完全一致, 不存在两套逻辑各跑各的可能。预设文件本身也不豁免:presets.json 里的值读回来时同样过一遍闸门, 手改配置文件塞进去的脏数据不会跑到命令行上。

为什么不用 dsh 的 settings 命名空间:当前 dsh 版本的 settings apiproxy 只服务硬编码的命名空间白名单, 第三方插件的命名空间一律答复 settings-not-exposed,浏览器侧既读不到也写不进。 自建一条同源 HTTP 桥(/api/local-model,注册在宿主 webServer 上)是这一版宿主上唯一可靠的通道。

桥的安全姿态

本机工具、没有账号体系,所以靠三条硬约束而不是鉴权:

  1. 只接受回环来源的请求 —— 配置变更与进程启停不该被局域网里的谁触发(/presets/* 也一样);
  2. 写操作要求 content-type: application/json —— 普通表单跨站提交做不到这一点, 于是即便有恶意页面在浏览器里跑,也发不出能改配置的请求;
  3. 请求体有大小上限。

因此:通过局域网/远程 Web UI 访问 dsh 时,这个面板会被拒绝(这是刻意的)。本地模型本来就是本机能力。

字段一览

类型、默认值与说明都写在设置面板里,这里只列关键分组:

分组关键字段
模型与目录enabled、selectedModel、mmprojFile(视觉投影文件)、mtp(多 Token 预测,开启后自动禁用视觉投影)、imageMinTokens / imageMaxTokens(每张图的 token 预算下限/上限)、modelsDir、runtimeDir、llamaServerPath、preload
服务与端口host(默认只听回环)、port(默认 18080)、llamaPort(默认 0 = 每次自动挑空闲端口)
推理参数ctxSize、maxTokens(单次最大输出 tokens)、nPredict(服务端默认输出上限)、guardContextOverflow(输出上限溢出保护,默认开)、gpuLayers、loadMode、threads、batchSize/ubatchSize、flashAttention、mmprojOffload、mtpWithVision(MTP 与视觉共存,默认开)、reasoningBudget(推理 token 预算)、reasoningBudgetMessage、reasoningEffort、jinja、chatTemplate、chatTemplateFile、chatTemplateKwargs、enableThinking、preserveThinking、mmap、mlock
多 Token 预测(MTP)细节specKvDtype、specDraftNMax、specDraftPMin(只在开了上面「多 Token 预测(MTP)」时生效)
KVMem 分块缓存(kvmem 分支专有)kvmemEnabled、kvmemBudget、kvmemGenReserve(解码预留 = 单次生成上限,构建默认仅 256)、kvmemBlockTokens、kvmemSinkTokens、kvmemRecentTokens、kvmemMethod、kvmemQueryLast、kvmemQueryMaxTokens、kvmemQueryReplay、kvmemQueryPolicy、kvmemMtpState、kvmemGpuRatio、kvmemCpuGb、kvmemNvmeGb、kvmemNvmeDir、kvmemHarvestV、kvmemRawKNvme。整组默认值都是「不下发」(数字 -1、字符串留空);官方 llama.cpp 构建上会被整体跳过并记日志
采样与 KV 缓存kvUnified(统一 KV 缓存)、kvStreamStageMib(KV 主机内存暂存 MiB)、temp、topK、topP、minP、presencePenalty、repeatPenalty、repeatLastN、seed
加载与卸载idleUnloadMinutes(默认 5)、startupTimeoutMs、shutdownGraceMs、autoRestart、maxRestarts
诊断与高级apiKey、extraArgs、envOverrides、logLevel(排查加载问题设 debug)

原先还有一组「接入 dsh」(routeName / modelAlias / routeModelId / contextWindow / registerRoute / exposeTool / allowModelControl)。0.4.0 起这一组已从 schema 删除, 改为 src/configResolve.ts 里的常量(取值 = 原默认值),因此行为不变但不再可配置。 详见 第 4 节「接入 dsh 那些设置去哪了」。

保存后模型会被卸载一次,下次对话按新参数重新加载 —— 用户刚改完设置,期望的就是这个行为。

插件会先探测你的 llama.cpp 再拼参数

同一个参数在不同版本的 llama.cpp 里形状会变。最典型的是 --flash-attn:

构建形状插件怎么发
新(如 b10822)`-fa, --flash-attn [onoff
老-fa, --flash-attn 裸开关--flash-attn(仅当设为 on)

如果不探测就直接按老形状发裸 flag,新构建的参数解析器会把下一个 token 当成它的值吃掉, 报出来的却是 unknown value for --flash-attn: '--mmproj' —— 错误信息指向被吞掉的那个参数, 排查方向会完全跑偏。

所以插件在起进程前先跑一次 --help,问清楚两件事:这个构建认识哪些选项、 --flash-attn 是哪种形状(结果按可执行文件缓存,每次加载只付一次代价)。顺带得到两个好处:

  • 不认识的选项不会被发出去(见「换了一个 llama 分支也能起来」一节)—— 不少分支对未知选项是直接 unknown flag: xxx + exit 1,不是忽略;
  • 万一探测结果与实际不符(或探测失败导致下发形状不对),服务报错后插件会自动去掉这个参数重试一次, 并把「已改为不下发」写进日志 —— 不必让用户去猜。

flashAttention 因此是三态而不是开关:auto(默认)永远不下发该参数。因为 auto 在所有支持三态的构建里 都等于默认值,在只支持裸开关的老构建里又根本表达不出来 —— 不下发是唯一在两种构建上语义都正确的做法。 历史配置里的 true/false 会自动收敛成 on/off。

另外:较新的 llama.cpp 已把 --mlock / --no-mmap 标记为 DEPRECATED(建议改用 --load-mode), 但两者仍然可用,插件继续沿用;将来若被移除(或换了不带它们的构建),门控会直接跳过并点名。

多 Token 预测(MTP)

0.3.1 新增,默认关闭。开启后加载时下发 --spec-type draft-mtp,让模型用它自带的预测头 一次猜测并校验多个 token(投机解码),本地生成速度通常能提升 1.2~2 倍,回复越长越明显。

MTP 与视觉投影的关系见下一节「MTP 与视觉共存」 —— 0.7.0 起两者默认可以同时开启, 互斥规则只有在关掉 mtpWithVision 时才恢复(上游 llama.cpp 需要那样)。

MTP 与视觉共存(0.7.0)

设置项 mtpWithVision,默认开启。上游 llama.cpp 里 MTP 与图像输入不能同时下发(会加载失败), 但 kvmem 分支的 llama-kvmem-server 支持:同时给 --mmproj 与 --spec-type draft-mtp 时 clip_model_loader: has vision encoder 与 creating MTP draft context 会同时出现、服务正常起来、 看图对话也能识别(2026-09-21 本机实测)。所以默认允许「开着 MTP 用图片」。

关掉它即恢复旧的互斥:MTP 生效、--mmproj 被忽略 —— 界面上下拉框会置灰、状态卡说明原因, 你选的 mmprojFile 不会被清空(免得来回切开关时丢配置)。换回官方 llama.cpp 的用户请关掉本项。

本次启动参数面板(0.7.0)

设置页最顶部会显示这次真正下发给 llama-server 的完整命令行(逐项一行),外加一张参数摘要 (含派生的「GPU KV 合计 = 工作集 + 解码预留」)和一个「复制命令行」按钮。

两个刻意的取舍:

  • 数据源是下发的 args,不是设置页里的配置。 两者之间隔着门控跳过、构建默认值与自动收敛 (-ub <= -b、-np 1 之类),只有 args 能回答「现在到底跑在什么参数上」。
  • 它还会列出「识别表没覆盖到的选项」。 识别表会随构建变化而过时;漏掉的项如果不说出来, 用户就永远不知道它被发了 —— 看不见的参数比错的值更危险。
事实说明