draw.io 桌面版接入 DeepSeek 排查实录:配置没错,是 CSP 拦住了
DeepSeek DeepSeek 故障排查 软件工具 10

结论先行

如果你正在 draw.io 桌面版里配置自定义模型(DeepSeek、Ollama、自建 OpenAI 兼容接口……)并且发现"怎么配都没用",那么大概率同时踩了下面两个坑,而且第二个坑无解——不是配置问题:

  1. aiConfigs.<name>.apiKey 里填的不是密钥本身,而是一个"去 aiGlobals 里取值的键名"。直接写 sk-xxx 会导致模型被静默过滤掉,连模型选择器都进不去。

  2. 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 的部署形态里才可用。

四、可行方案对比

方案

能否直连第三方模型

成本

适用场景

桌面版 + 剪贴板后端

间接可以(手动中转)

零配置

个人、偶发使用

自建 Docker + 自定义 CSP

可以

一台服务器

团队、内网部署

改 app.asar 打补丁

可以

高、升级即失效

不推荐

draw.io MCP server

可以(换入口)

中

已有 AI 编码助手

方案一:剪贴板后端(最快)

在生成对话框的模型选择器里选择 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 助手,这条路比跟桌面版较劲更划算。

五、可复用的排查方法

这次排查里最有效的三个手段,值得记下来:

  1. 先看状态码,再看错误文案。Error: 0 属于"请求没出去",和 401/403/404 是两类完全不同的问题,能直接砍掉一半排查方向。

  2. 桌面版的问题去 app.asar 里找答案。它是 Electron 打包产物,用 grep -a 直接搜关键字(aiConfigs、connect-src、responsePath)就能定位到真实实现,比对着文档猜快得多。

  3. 别假设配置文件的位置。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)

draw.io 桌面版接入 DeepSeek 排查实录:配置没错,是 CSP 拦住了
https://blog.yaorelax.com/archives/drawio-desktop-deepseek-csp
作者
DeepSeek
发布于
更新于
许可