结论先行
如果你正在 draw.io 桌面版里配置自定义模型(DeepSeek、Ollama、自建 OpenAI 兼容接口……)并且发现"怎么配都没用",那么大概率同时踩了下面两个坑,而且第二个坑无解——不是配置问题:
aiConfigs.<name>.apiKey里填的不是密钥本身,而是一个"去aiGlobals里取值的键名"。直接写sk-xxx会导致模型被静默过滤掉,连模型选择器都进不去。draw.io 桌面版有严格的 CSP,
connect-src只放行'self'和*.draw.io / *.diagrams.net。任何第三方 AI 端点都会被浏览器层拦截,表现是出错: Error: 0。这是官方"by design"的行为,桌面版没有开关。
所以:模型选择器里看不到模型 → 是坑一;模型出现了但一发请求就 Error: 0 → 是坑二。想要真正直连第三方模型,目前只有自建(Docker)这条路。
一、问题现象
环境:draw.io Desktop 31.4.5(Windows),目标是接入 DeepSeek 官方 API(模型 deepseek-flash)。
配置写进 Extras → Configuration 之后,经历了两个阶段:
阶段一:配置"看起来完全正确",但生成对话框的模型选择器里根本没有 DeepSeek 这一项。
阶段二:把密钥相关配置改对之后,模型出现了,但发送任意请求都返回
出错: Error: 0。
二、第一个坑:apiKey 填的不是密钥,是"名字"
2.1 最初的配置
按照直觉写出来的配置是这样的:
{
"aiModels": [
{ "name": "DeepSeek Flash", "model": "deepseek-flash", "config": "deepseek" }
],
"enableAi": true,
"aiConfigs": {
"deepseek": {
"apiKey": "sk-xxxxxxxx",
"endpoint": "https://api.deepseek.com/v1/chat/completions",
"requestHeaders": { "Authorization": "Bearer {apiKey}" },
"request": {
"model": "{model}",
"messages": [
{ "role": "system", "content": "{action}" },
{ "role": "user", "content": "{prompt}" }
]
},
"responsePath": "$.choices[0].message.content"
}
}
}结构上完全对齐官方文档里内置的 gpt 配置,看起来没毛病。
2.2 源码里的事实
把桌面版的 app.asar 解出来看聊天窗口的代码,模型列表是这么构建的:
// 只有 aiGlobals[aiConfigs[config].apiKey] 非空,模型才会被加进选择器
null != Editor.aiConfigs[f.config] &&
null != Editor.aiGlobals[Editor.aiConfigs[f.config].apiKey] &&
D.push({ id: "model-" + f.name, kind: "model", label: f.name, model: f });关键在第二行:它把 aiConfigs.xxx.apiKey 的值当作键名,再去 Editor.aiGlobals 里查一次。而官方内置配置写的是:
"gpt": {
"apiKey": "gptApiKey", // 注意:这是"名字",不是密钥
...
}gptApiKey 是顶层的一个配置项,它的值才会被搬进 aiGlobals。占位符解析也是同一套逻辑:
tb = function(Sa) {
var Ua = null;
"prompt" == Sa ? Ua = ja :
"data" == Sa && null != na ? Ua = na.data :
"model" == Sa ? Ua = bb.model :
"apiKey" == Sa ? Sa = ab.apiKey : // 变成键名
"action" == Sa && (Sa = na != null ? "update" : "create");
null == Ua && (Ua = Editor.replacePlaceholders(Editor.aiGlobals[Sa], tb));
return Ua;
};于是 "apiKey": "sk-xxxxxxxx" 会去查 aiGlobals["sk-xxxxxxxx"] → undefined → 模型被过滤;即使绕过,{apiKey} 也会被解析成空字符串,请求头变成 Authorization: Bearer 。
还有一个容易忽略的点:顶层配置项里只有 gptApiKey / geminiApiKey / claudeApiKey 三个会被自动搬进 aiGlobals:
null != d.gptApiKey && (Editor.aiGlobals.gptApiKey = d.gptApiKey);
null != d.geminiApiKey && (Editor.aiGlobals.geminiApiKey = d.geminiApiKey);
null != d.claudeApiKey && (Editor.aiGlobals.claudeApiKey = d.claudeApiKey);
null != d.aiGlobals && (Editor.aiGlobals = d.aiGlobals); // 整体替换
null != d.aiConfigs && (Editor.aiConfigs = d.aiConfigs); // 整体替换所以自己造一个 "deepseekApiKey": "sk-..." 写在顶层是没用的,它不会被搬进 aiGlobals。
2.3 附带结论:aiConfigs / aiGlobals 是整体替换,不是合并
这意味着:一旦你自定义了 aiConfigs,内置的 gpt / gemini / claude 配置就全部消失了。如果此时 aiModels 里还写着 "config": "gpt",同样会被过滤掉——这是另一个常见的"模型不出现"原因。
aiGlobals 同理:它保存着 create / update / assist 三个系统提示词,整体替换时若不补回来,{action} 会解析成空字符串,模型就失去了"输出 Mermaid 或 draw.io XML"的指令。
2.4 修正后的配置
最省事的做法是借用 gptApiKey 这个槽位(内置 gpt 配置已被覆盖,不冲突),这样既不用重写系统提示词,也能让密钥正常进入 aiGlobals:
{
"enableAi": true,
"gptApiKey": "sk-xxxxxxxx",
"aiModels": [
{ "name": "DeepSeek Flash", "model": "deepseek-flash", "config": "deepseek" }
],
"aiConfigs": {
"deepseek": {
"apiKey": "gptApiKey",
"endpoint": "https://api.deepseek.com/v1/chat/completions",
"requestHeaders": { "Authorization": "Bearer {apiKey}" },
"request": {
"model": "{model}",
"messages": [
{ "role": "system", "content": "{action}" },
{ "role": "user", "content": "{prompt}" }
]
},
"responsePath": "$.choices[0].message.content"
}
}
}另一种更"正统"的写法是自建 aiGlobals,但必须把三段默认提示词一起补上:
{
"aiGlobals": {
"deepseekApiKey": "sk-xxxxxxxx",
"create": "(默认 create 提示词)",
"update": "(默认 update 提示词)\n{data}",
"assist": "(默认 assist 提示词)"
},
"aiConfigs": {
"deepseek": { "apiKey": "deepseekApiKey", "...": "..." }
}
}改完点 Apply 并重载应用,模型就会出现在选择器里了——然后你就会撞上第二个坑。
三、第二个坑:CSP 按设计封锁第三方 AI 端点
3.1 Error: 0 是什么
聊天窗口处理响应时是这样的:
if (200 <= qb.getStatus() && 299 >= qb.getStatus()) {
// 正常解析 responsePath
} else {
Za = "Error: " + qb.getStatus();
...
}HTTP 状态码 0 只有一种含义:请求压根没发出去(被浏览器拦截,或网络层就失败了),而不是服务端返回了错误。对应的报错就是界面上的 出错: Error: 0。
3.2 桌面版的两层 CSP
桌面版给自己套了两道内容安全策略。渲染进程注入的 meta 标签:
default-src 'self';
script-src 'self' 'sha256-...';
connect-src 'self' https://*.draw.io https://*.diagrams.net
https://fonts.googleapis.com https://fonts.gstatic.com;
img-src * data:; media-src *; font-src * data:; frame-src 'self';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
base-uri 'none'; child-src 'self'; object-src 'none';主进程里还有一层更严的(源码注释原话是 "the strictest of multiple policies wins"):
'Content-Security-Policy': ["default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'" +
(isGoogleFontsEnabled ? ' https://fonts.googleapis.com https://fonts.gstatic.com' : '') + "; ..."]https://api.deepseek.com 不在任何一份白名单里,XHR 在发出前就被 CSP 拒了。内置的 GPT / Claude / Gemini 直连配置在桌面版同样如此——桌面版真正能用的只有它自己托管的那个后端,以及"剪贴板"后端。
3.3 官方立场:by design,not planned
jgraph/drawio-desktop 的 issue #2460 原文写得很直白:
"AI cannot be enabled in the Desktop version due to CSP limitations (by design). This includes pointing to custom AI endpoints on the localhost."
该 issue 已被标记为 not planned 关闭。桌面版也没有任何 CSP 相关的配置项或启动参数——urlParams 和配置键里都搜不到。
如果你想亲眼确认,打开 Help → Open DevTools,Console 里会有一条:
Refused to connect to 'https://api.deepseek.com/...' because it violates
the following Content Security Policy directive: "connect-src ..."3.4 换成网页版就行吗?也不行
网页版(app.diagrams.net)的 CSP 由服务端下发,connect-src 白名单里同样只有:
'self' https://*.draw.io https://*.diagrams.net https://*.googleapis.com
https://api.openai.com https://api.anthropic.com
https://api.github.com https://graph.microsoft.com ... (业务域名若干)没有 api.deepseek.com,也没有 localhost。也就是说,自定义模型端点只在你能控制 CSP 的部署形态里才可用。
四、可行方案对比
方案一:剪贴板后端(最快)
在生成对话框的模型选择器里选择 Clipboard / 剪贴板。它会把「系统提示词 + 附件里的图 XML + 你的输入」一起复制到剪贴板,你贴进 DeepSeek 网页或 App,再把回复贴回来点 Paste Response / 粘贴响应。draw.io 会照常渲染结果(Mermaid 会自动转成 draw.io XML),并给出插入 / 应用按钮。这是桌面版官方提供的"使用外部模型"路径。
方案二:自建 Docker 版(能真正直连)
官方镜像暴露了两个正好对症的环境变量:
DRAWIO_CONFIG:整份 JSON 配置DRAWIO_CSP_HEADER:覆盖 CSP 响应头
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
environment:
DRAWIO_CONFIG: '{"enableAi":true,"gptApiKey":"sk-xxxxxxxx","aiModels":[{"name":"DeepSeek Flash","model":"deepseek-flash","config":"deepseek"}],"aiConfigs":{"deepseek":{"apiKey":"gptApiKey","endpoint":"https://api.deepseek.com/v1/chat/completions","requestHeaders":{"Authorization":"Bearer {apiKey}"},"request":{"model":"{model}","messages":[{"role":"system","content":"{action}"},{"role":"user","content":"{prompt}"}]},"responsePath":"$.choices[0].message.content"}}}'
DRAWIO_CSP_HEADER: "……保留默认值,并在 connect-src 中追加 https://api.deepseek.com……"注意:DRAWIO_CSP_HEADER 是整体替换而非追加。建议先把镜像的默认值抄下来,只在 connect-src 里加一项,否则会连带丢掉 Dropbox / GitHub / OneDrive 等业务域名的放行。
方案三:给 app.asar 打补丁(不推荐)
把上述两处 CSP 字符串里补上目标域名再重新打包。技术上可行,但每次应用升级都会失效,需要持续维护。
方案四:换个入口——draw.io MCP server
官方维护的 drawio-mcp 让 AI 编码助手直接生成和操作 draw.io 图形,完全绕开客户端 CSP。如果你的工作流里已经有一个支持自选模型(比如 DeepSeek)的 AI 助手,这条路比跟桌面版较劲更划算。
五、可复用的排查方法
这次排查里最有效的三个手段,值得记下来:
先看状态码,再看错误文案。
Error: 0属于"请求没出去",和 401/403/404 是两类完全不同的问题,能直接砍掉一半排查方向。桌面版的问题去
app.asar里找答案。它是 Electron 打包产物,用grep -a直接搜关键字(aiConfigs、connect-src、responsePath)就能定位到真实实现,比对着文档猜快得多。别假设配置文件的位置。draw.io 桌面版有两套存储:
%APPDATA%\draw.io\config.json只放窗口尺寸之类的应用状态;而编辑器配置(含 AI 配置)存放在渲染进程的 localStorage 里,改错文件当然没有任何效果。正规入口始终是 Extras → Configuration。
六、小结
这次踩的两个坑其实是同一类问题的两种表现:draw.io 的 AI 配置是一套"面向实现"的 DSL,而不是面向使用者的 API。字段名相同(apiKey)但语义不同(引用名 vs 密钥),文档里那句轻描淡写的 "A model is only offered when its config has an API key configured",背后其实是一次 aiGlobals 的间接取值。
而 CSP 这条限制则提醒我们:Electron 桌面应用"看起来像本地软件",运行时却仍受浏览器安全模型的约束。桌面版的 CSP 是刻意收紧的——用"能不能从渲染进程直连任意域名"换取攻击面收敛。理解了这一点,"为什么网页版能配、桌面版不行"就不再费解了。
附:相关资源
draw.io 官方文档:Customise LLM backends for diagram generation(
aiModels/aiConfigs/aiGlobals参数说明)jgraph/drawio-desktop issue #2460:Allow AI diagram generation via configurable CSP whitelist
jgraph/drawio discussion #5387:Using Custom LLM Backends in draw.io(Docker 环境的 CSP 处理)
DeepSeek API 文档:模型与价格(
deepseek-flash/deepseek-v4-pro)