@liustack/modlens 3.14.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ ---
2
+ summary: '输出契约:每次识别返回的 JSON 结构、result 字段与 meta'
3
+ read_when:
4
+ - 解析 modlens 输出或在它之上构建工具
5
+ - 查 meta.attempts 和 meta.warnings 的含义
6
+ ---
7
+
8
+ # ModLens 输出契约(v2)
9
+
10
+ [English](output-schema.md) | 中文
11
+
12
+ CLI 向 stdout 打印一个 JSON 对象:
13
+
14
+ ```json
15
+ {
16
+ "image": "/abs/path/or/url",
17
+ "provider": "antigravity-cli",
18
+ "result": { "...": "see below" },
19
+ "meta": {
20
+ "generatedAt": "2026-08-01T12:00:00.000Z",
21
+ "model": "gemini-3.6-flash-low",
22
+ "conversationId": "string|null",
23
+ "durationSeconds": 25.4,
24
+ "usage": {},
25
+ "attempts": [{ "provider": "antigravity-cli", "ok": true, "durationSeconds": 25.4 }],
26
+ "warnings": []
27
+ }
28
+ }
29
+ ```
30
+
31
+ `meta.attempts` 按顺序列出这次运行中故障转移链尝试过的每个 provider,失败时附带 `error` 字符串。`meta.warnings` 携带路由通知:故障转移、被忽略的 `extraBody`,以及自动模式下这次识别花了谁的额度。
32
+
33
+ 只要 provider 支持,`result` 就由 JSON schema 强制约束(agent CLI 走 `--json-schema`,API provider 走 response-schema 字段或预填模板),CLI 返回前还会自己校验一遍结构,所以结构损坏的结果会触发故障转移,不会到你手上:
34
+
35
+ ```json
36
+ {
37
+ "summary": "string",
38
+ "ocr": {
39
+ "full_text": "string",
40
+ "lines": [
41
+ { "text": "string", "language": "string (optional)" }
42
+ ]
43
+ },
44
+ "layout": {
45
+ "regions": [
46
+ {
47
+ "type": "title|subtitle|paragraph|list|table|chart|form|code|image|icon|other",
48
+ "reading_order": 1,
49
+ "text": "string"
50
+ }
51
+ ]
52
+ },
53
+ "semantics": {
54
+ "scene": "string",
55
+ "intent": "string (optional)",
56
+ "entities": [
57
+ { "name": "string", "type": "string", "evidence": "string (optional)" }
58
+ ],
59
+ "relations": [
60
+ { "subject": "string", "predicate": "string", "object": "string" }
61
+ ]
62
+ },
63
+ "visual": {
64
+ "dominant_colors": ["string"],
65
+ "style": "string",
66
+ "notes": ["string"]
67
+ },
68
+ "uncertainty": ["string"]
69
+ }
70
+ ```
71
+
72
+ 必填字段:`summary`、`ocr`、`layout`、`semantics`、`visual`、`uncertainty`,也就是每一个顶层字段,`visual` 也不例外。(早期文档把 `visual` 写成可选,但强制执行的 schema 一直要求它,请以 schema 为准。)
73
+
74
+ 相对 v1 的变化:删掉了像素级 `bbox` 坐标和数值型 `confidence` 分数。视觉模型会凭空编造这两样,v2 不再假装提供。`layout.regions[].type` 新增了 `code`。
package/docs/security.md CHANGED
@@ -8,6 +8,8 @@ read_when:
8
8
 
9
9
  # Security
10
10
 
11
+ English | [中文](security.zh-CN.md)
12
+
11
13
  ## Recovered images are private
12
14
 
13
15
  Images pulled out of session storage are written 0600 into a 0700 directory. By default that directory is a fresh, unpredictable `<tmpdir>/modlens-paste-*` minted per run, so nobody on a shared machine can pre-create a known path (`recursive` mkdir leaves an existing directory's mode alone) and read the bytes. A pasted screenshot can hold anything. An explicit `--out-dir` is honoured but refused when unsafe: it must be a real directory, not a symlink, owned by you, with no group or world access.
@@ -0,0 +1,43 @@
1
+ ---
2
+ summary: '安全:modlens 会运行什么、恢复出的图片如何受保护、图片内容属于不可信输入'
3
+ read_when:
4
+ - 审查这个工具会在你的机器上做什么
5
+ - 在共享机器上恢复粘贴的图片
6
+ - 判断视觉引擎的报告可信到什么程度
7
+ ---
8
+
9
+ # 安全
10
+
11
+ [English](security.md) | 中文
12
+
13
+ ## 恢复出的图片是私有的
14
+
15
+ 从会话存储里取出的图片以 0600 权限写入 0700 目录。默认这个目录是每次运行新建的、不可预测的 `<tmpdir>/modlens-paste-*`,共享机器上没人能预先创建一个已知路径来读取字节(`recursive` 的 mkdir 不会改动已存在目录的权限模式)。一张粘贴的截图里可能有任何东西。显式传入的 `--out-dir` 会被接受,但不安全时会被拒绝:它必须是真实目录,不是符号链接,归你所有,组和其他用户无任何权限。
16
+
17
+ 恢复同样限定在单个项目内:检查的是 transcript 里记录的工作目录,不只是目录名,因为目录 slug 会撞车(`/tmp/a.b` 和 `/tmp/a-b` 生成同一个 slug)。相邻项目的图片绝不会被交出去。
18
+
19
+ ## 传给引擎的权限
20
+
21
+ ModLens 调用 `agy` 时带上 `--dangerously-skip-permissions`,因为某些环境下 prompt 模式不带它就会失败。prompt 把 agent 限制为只读交给它的那一张图片,并指示它把图片内容严格当作数据。
22
+
23
+ `claude-cli` provider 只带 `--allowedTools Read` 运行,因此它能读本地文件,别的什么都做不了。
24
+
25
+ 两个子进程 provider 还都运行在一个用完即弃的目录里,每次调用新建,结束后删除。本地图片时,目录里只有那张图片的一份私有副本,别无他物,而且是真实拷贝,绝不用硬链接,provider 往自己的临时路径写东西也碰不到原文件。远程图片时目录是空的,agent 把文件下载进去。没有这层隔离,图片里的文字就可能引导一个权限宽泛的 agent 去读原图旁边的文件,或读调用者恰好所在的项目。传 `--workdir` 即放弃这层隔离,在你指定的位置运行。
26
+
27
+ 这是在缩小暴露面,不是操作系统沙箱:agent 仍然能读绝对路径、访问网络、启动进程。把它当作更收紧的默认值,而不是安全边界。不信任的图片优先用 `-p gemini-api`,它自己下载字节,不运行任何本地 agent。远程 URL 本来就优先走内联这一侧:远程 URL 的故障转移链先试内联的 API provider,agent 排在最后。远程 URL 实际由谁抓取因 provider 而异,而只有本地下载能被本地防护覆盖:
28
+
29
+ | Provider | 远程 URL 由谁抓取 | 本地防护 |
30
+ | :-- | :-- | :-- |
31
+ | `gemini-api` | modlens 自己下载,字节内联发送 | 私有地址防护、文件头魔数(magic bytes)检查、25 MB 上限 |
32
+ | `openai`、`anthropic` | URL 传给厂商,由厂商抓取 | 本地无,适用厂商自己的抓取策略 |
33
+ | `antigravity-cli`、agent CLI | agent 自行抓取 | 本地无 |
34
+
35
+ 所以私有地址防护、文件头魔数检查和大小上限保护的恰好是 modlens 亲自下载的路径:每一次本地文件读取,加上 gemini-api 的远程抓取。显式 `-p` 钉死单个 provider,覆盖整条链。
36
+
37
+ ## 图片内容是不可信输入
38
+
39
+ 图片里的文字是不可信的,和网页一样。一张截图可能带着写给任何读取者的指令。prompt 里明确说了这一点,但那是缓解措施,不是保证:只分析你愿意打开的图片,图片来自别处时优先用沙箱化的工作目录。
40
+
41
+ ## 证据,而非编造
42
+
43
+ 引擎读不出来的内容会进入 `uncertainty`,而不是被脑补出来。v2 彻底去掉了像素坐标和置信度分数,因为这两个字段正是模型编造得最像真的。
@@ -8,6 +8,8 @@ read_when:
8
8
 
9
9
  # Troubleshooting
10
10
 
11
+ English | [中文](troubleshooting.zh-CN.md)
12
+
11
13
  Start with `modlens doctor`: it checks your Node version, which providers are ready, which one will be selected and why, and the detected harness, all without spending quota or making a network request. It catches most setup problems before you read any further.
12
14
 
13
15
  Every message below is one modlens actually prints. Search this file for the words you saw.
@@ -0,0 +1,210 @@
1
+ ---
2
+ summary: '故障排查:modlens 可能打印的每一条报错、成因与解法'
3
+ read_when:
4
+ - 运行失败了,报错信息看不明白
5
+ - recover-paste 什么都没找到,或找到了错的图片
6
+ - 判断一次失败属于配置问题、额度问题还是 bug
7
+ ---
8
+
9
+ # 故障排查
10
+
11
+ [English](troubleshooting.md) | 中文
12
+
13
+ 先跑 `modlens doctor`:它会检查你的 Node 版本、哪些 provider 已就绪、将选中哪一个及其原因,以及检测到的 harness,全程不消耗额度,也不发网络请求。大多数配置问题在你继续往下读之前就能被它查出来。
14
+
15
+ 下面每条消息都是 modlens 实际会打印的。拿你看到的字眼在本文里搜索即可。
16
+
17
+ ## Antigravity CLI 读不到已保存的登录令牌
18
+
19
+ ```
20
+ Antigravity CLI cannot read its stored login token.
21
+
22
+ On Linux this usually means the OS keyring is locked, which is normal for headless
23
+ sessions (agents, cron, systemd, SSH without a desktop login) ...
24
+ ```
25
+
26
+ agy 把令牌存在操作系统钥匙串里。钥匙串被锁定时,agy 会把自己报告为未登录,并尝试浏览器登录,而没有显示器时这个流程无法完成。三条出路:
27
+
28
+ - 解锁钥匙串,或在桌面会话里运行 modlens。
29
+ - 用 `agy` 重新登录。
30
+ - 换一个不需要交互式登录的 provider:
31
+
32
+ ```bash
33
+ modlens config set gemini-api.apiKey <key> # free key: https://aistudio.google.com
34
+ modlens config set provider gemini-api
35
+ ```
36
+
37
+ ## 额度用尽
38
+
39
+ ```
40
+ Individual quota reached. ... Resets in 94h19m9s.
41
+
42
+ agy's free tier is one weekly bucket shared by the desktop app, the CLI, and the SDK ...
43
+ ```
44
+
45
+ 等重置,或换到 `gemini-api`,它有自己独立的预算。并行的 subagent 会飞快耗干这个共享额度池,用得猛的一天就能把它用完。
46
+
47
+ ## 找不到 provider CLI
48
+
49
+ ```
50
+ Provider CLI not found: agy. Install it and sign in first.
51
+ ```
52
+
53
+ 二进制不在 PATH 上,或者 `--provider-bin` 指错了地方。
54
+
55
+ ```
56
+ Working directory does not exist: /some/path
57
+ ```
58
+
59
+ 成因不同,但操作系统返回的是同一个底层错误码:`--workdir` 指向了一个不存在的目录。二进制本身没问题。
60
+
61
+ ## recover-paste 什么都没找到
62
+
63
+ ```
64
+ No pasted images found in any session storage for this directory (looked in: ...)
65
+ ```
66
+
67
+ 按可能性从高到低:
68
+
69
+ - **你在错误的目录里。**恢复只限于对话所在的项目。传 `--cwd /path/to/project`。
70
+ - **根本没有粘贴过。**拖进来的文件和手打的路径本来就是真实文件,没有什么可恢复的:直接用那个路径。
71
+ - **某个配置问题挡住了一个 harness。**被挡的原因会出现在同一条消息的 `Blocked:` 之后,例如 OpenCode 需要 Node 22.13+ 才能用 `node:sqlite`。
72
+
73
+ ## recover-paste 返回了另一个项目的图片
74
+
75
+ 这种情况现在不应该再出现了,真出现就是值得上报的 bug。恢复检查的是 transcript 里记录的工作目录,不只是目录名,因为目录 slug 会撞车(`/tmp/a.b` 和 `/tmp/a-b` 生成同一个 slug)。提 issue 时带上输出里的 `harness` 和 `transcript` 字段。
76
+
77
+ ## 项目对了,图片恢复错了
78
+
79
+ 输出按从旧到新排列,所以**最后**一条才是最近一次粘贴。harness 存了文件名时条目会带 `filename`:用户提到名字时按它来匹配。`--count 3` 能多给几个候选。
80
+
81
+ ## recover-paste:覆盖检测结果与输出位置
82
+
83
+ `recover-paste` 会自动检测自己运行在哪个 harness 里(先看进程祖先,再看环境特征),并且只读那个 harness 的存储。两个旋钮可以覆盖它:
84
+
85
+ - **`MODLENS_HARNESS`** 不用命令行参数就能强制指定存储范围:`claude-code`、`pi`、`opencode`、`codex`,或 `none`(扫描所有存储,不限范围)。检测最先读它,所以它优先于进程祖先和环境特征。`--harness` 对单次运行做同样的事。
86
+ - **`--out-dir`** 决定恢复出的图片落在哪。默认每次运行都新建一个不可预测的 `<tmpdir>/modlens-paste-*` 目录(0700,内含 0600 文件),没人能预先创建一个共享路径来截获字节。系统临时目录不合适时可以指到别处。显式传入的 `--out-dir` 若已存在,必须是真实目录(不是符号链接)、归你所有、组和其他用户无任何权限,否则会被拒绝。Windows 上会跳过所有权和权限检查,因为该平台没有 POSIX 权限位(见下方 Windows 一节)。符号链接检查仍然生效。
87
+
88
+ ## 这是一个 Codex 会话
89
+
90
+ ```
91
+ This is a Codex session: pasted images already exist as temp files, and each image
92
+ tag in the message carries its path.
93
+ ```
94
+
95
+ 一切符合设计。Codex 会把粘贴的图片写到磁盘,并把路径放进消息里,所以直接从 tag 里取路径来读,不需要恢复任何东西。
96
+
97
+ ## openai provider 的结果被拒绝
98
+
99
+ ```
100
+ OpenAI-compatible API returned JSON that does not match the vision schema
101
+ (missing: ocr, ocr.full_text, ...)
102
+ ```
103
+
104
+ 那个端点返回了残缺的结果。只有 agy、gemini-api、anthropic 和 claude-cli 在服务端强制执行 schema,较弱的网关可能只产出半个结果。重试一次,然后换 provider:
105
+
106
+ ```bash
107
+ modlens -i <image> -p gemini-api
108
+ ```
109
+
110
+ ## guard 给出了 deny,或一次读取被拒绝
111
+
112
+ ```
113
+ Invocation guard denied this read: active model "gemini-3.1-pro" matches guards.denyModels pattern "gemini-3*". A model with native vision should read the image itself. To override, unset MODLENS_MODEL or edit guards in /Users/you/.modlens/config.json.
114
+ ```
115
+
116
+ 这是配置在按预期工作:配置文件里的 `guards.denyModels` 列出了自带视觉的模型,当前模型匹配到了其中一条,引擎因此拒绝为一张该模型自己就能读的图片花掉一次 provider 调用。`modlens doctor` 有一个 Guard 小节,展示规则、检测到的模型、来自哪个信号(`MODLENS_MODEL` 环境变量、会话存储或 `--model` 自报),以及判定结果。
117
+
118
+ 如果检测错了,`MODLENS_MODEL=<actual-model> modlens guard` 覆盖一切,`MODLENS_MODEL=none` 把模型标为未知(判定随 `denyWhenUnknown` 走,默认 allow)。彻底关掉 guard:`modlens config set guards.denyModels ''`。
119
+
120
+ 一个已知盲区:存储检测读的是这个项目记录的最新一条 assistant 轮次,所以同一个项目目录里同时跑着不同模型的两个会话可能互相遮蔽(Claude Code 和 Codex 通过注入的会话 id 锁定确切会话,Pi 和 OpenCode 做不到)。中招时用 `MODLENS_MODEL` 覆盖。
121
+
122
+ 注意上面那种硬拒绝只在显式的 `MODLENS_MODEL` 值真正匹配到 `denyModels` 时才触发。存储检测和 `denyWhenUnknown` 策略从不阻断 `analyze`,它们只通过 `modlens guard` 发声,而 guard 的 deny 是给 agent 的建议,不是上了锁的门。
123
+
124
+ ## dsh 提示 `declares no dsh.bundle — installed as a plain dependency`
125
+
126
+ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存在,而 pnpm v11 的发布冷静期机制(`minimumReleaseAge`,隔离刚发布的版本,pnpm 11.21 上实测窗口为 10 天)在所有较新版本都在窗口内时,会静默回退到更旧的版本。那个旧版本没有 bundle 声明,dsh 于是正确地把它当作普通依赖,一个工具都不会出现。
127
+
128
+ 解法:把版本写死。pnpm 只在解析版本范围时应用冷静期,显式版本或 dist-tag 会跳过它([pnpm#9989](https://github.com/pnpm/pnpm/issues/9989),pnpm 11.21 上实测),安装命令带 `@latest` 就是这个原因:
129
+
130
+ ```sh
131
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@latest
132
+ ```
133
+
134
+ dsh 的 reconcile 会注意到新版本上的 bundle 声明并激活它,随后重启 dsh。用 `npx -y @deepseek-ai/dsh plugin --profile <name> list` 验证,显示的版本应该是 3.9.0 或更新。
135
+
136
+ 如果将来的 pnpm 关掉了这条跳过通道,更持久的替代方案是在 `~/.dsh/profiles/<name>/pnpm-workspace.yaml` 里加一条一次性排除(写裸包名,不写 `name@version`,这样以后的新版本也能沿用):
137
+
138
+ ```yaml
139
+ minimumReleaseAgeExclude:
140
+ - '@liustack/modlens'
141
+ ```
142
+
143
+ 然后执行 `npx -y @deepseek-ai/dsh plugin --profile <name> update @liustack/modlens`。两种方式的代价都摆在明面上:显式 `@latest`(或这条排除)让 modlens 退出 pnpm 的供应链冷静期,新版本会立即装上。
144
+
145
+ ## fetch failed 或连接失败
146
+
147
+ ```
148
+ Could not connect to generativelanguage.googleapis.com (UND_ERR_CONNECT_TIMEOUT). The request never reached the network. ...
149
+ ```
150
+
151
+ API 请求根本没离开这台机器。在要靠代理才能上网的网络里这是预期表现:Node 的 fetch 默认无视代理环境变量。你明确要求走代理后 modlens 才会遵循,两种写法任选:
152
+
153
+ ```bash
154
+ HTTPS_PROXY=http://127.0.0.1:7890 modlens -i shot.png -p gemini-api # env (NO_PROXY honored too)
155
+ modlens config set proxy http://127.0.0.1:7890 # persistent, all API providers
156
+ modlens config set openai.proxy http://127.0.0.1:7890 # one provider only
157
+ ```
158
+
159
+ 代理只作用于 API provider 的请求。远程图片的下载路径有意保持直连并钉死 IP:它的 SSRF 防护校验的正是实际连接的那个地址,加了代理这些防护就失明了。在必须走代理的机器上,优先用本地文件,或让故障转移链把远程 URL 交给会在上游自行抓取的 provider。
160
+
161
+ ## 配置文件问题
162
+
163
+ ```
164
+ Cannot read /Users/you/.modlens/config.json: EACCES ... Fix the file or its permissions.
165
+ ```
166
+
167
+ 文件存在但读不了。文件缺失是正常的,所以这是真问题,不能无视。
168
+
169
+ ```
170
+ Failed to parse ... Fix or delete the file.
171
+ ```
172
+
173
+ JSON 无效。`modlens config init --force` 会写入一份干净的配置,旧内容会丢失。
174
+
175
+ ## 超时
176
+
177
+ ```
178
+ antigravity-cli provider timed out after 210000 ms.
179
+ ```
180
+
181
+ 带 `--timeout 300000` 重试一次。信息密集的图片在 agy 上花 15-40 秒属于正常,`-m gemini-3.1-pro-high` 还会更慢。无视 SIGTERM 的引擎会被升级为 SIGKILL,所以超时无论如何都会迅速返回。
182
+
183
+ ## 推理模型上每次读取都很慢
184
+
185
+ 默认思考的模型会在开始转录之前先把预算花在思考上,而视觉读取并不需要思考。没有统一的 `--no-thinking` 参数,因为每家厂商给这个开关起的名字都不一样,所以直接传厂商自己的字段:
186
+
187
+ ```bash
188
+ modlens config set openai.extraBody '{"thinking":{"type":"disabled"}}'
189
+ modlens -i shot.png --extra-body '{"reasoning_effort":"low"}' # one run only
190
+ ```
191
+
192
+ 各家厂商的具体写法、哪些模型完全关不掉,以及怎么确认字段真的生效,见[配置手册](../skills/modlens/references/configure.zh-CN.md#关闭思考)。
193
+
194
+ ```
195
+ extraBody cannot override "messages" for the openai provider
196
+ ```
197
+
198
+ 这个字段承载着图片、prompt 和 schema 强制逻辑。把它删掉,只保留厂商的开关字段。网关返回 400 并点名你设置的某个字段,说明那个端点用的是另一种写法。在 `antigravity-cli` 或 `claude-cli` 上运行时,`meta.warnings` 会说明该值被忽略了,因为 CLI provider 没有请求体。
199
+
200
+ ## Windows
201
+
202
+ ModLens 可以在 Windows 上运行。三个值得了解的平台差异:
203
+
204
+ - **没有 POSIX 权限检查。**Windows 文件没有所有者、组、其他用户的权限位(读出来是 `0o666`/`0o777`,实际访问由 ACL 控制),所以 `doctor` 不评判配置文件的权限模式,`recover-paste --out-dir` 也不会因所有权或组和其他用户的权限而拒绝目录。`--out-dir` 的符号链接检查仍然生效。
205
+ - **Harness 检测依赖环境特征。**没有 `ps` 可以读进程树,检测只能依靠各 harness 设置的环境变量。猜错时用 `--harness <name>` 或 `MODLENS_HARNESS` 强制指定。
206
+ - **粘贴恢复。**OpenCode 的恢复在 Windows 上已覆盖(issue #11)。Claude Code 和 Pi 的 JSONL 路径依赖 `os.homedir()` 和各 harness 在那里的磁盘 slug。恢复扑空时,用 `--transcript` 直接指向文件,或把图片拖进终端。
207
+
208
+ ## 还是没解决
209
+
210
+ 提 issue 时附上完整命令和完整报错:https://github.com/liustack/modlens/issues
package/dsh/client.js CHANGED
@@ -20,7 +20,7 @@ window.__ModuleLoader__.load({
20
20
  var exports = module.exports
21
21
 
22
22
  function imageFilesOf(event) {
23
- var items = event.clipboardData && event.clipboardData.items
23
+ var items = event.clipboardData?.items
24
24
  if (!items) return []
25
25
  var files = []
26
26
  for (var i = 0; i < items.length; i++) {
@@ -33,10 +33,7 @@ window.__ModuleLoader__.load({
33
33
  }
34
34
 
35
35
  function insertText(target, text) {
36
- var el =
37
- target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT')
38
- ? target
39
- : document.activeElement
36
+ var el = target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') ? target : document.activeElement
40
37
  if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) return
41
38
  el.focus()
42
39
  // execCommand fires the input event React's controlled textarea needs;
@@ -48,8 +45,7 @@ window.__ModuleLoader__.load({
48
45
  inserted = false
49
46
  }
50
47
  if (!inserted) {
51
- var proto =
52
- el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
48
+ var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
53
49
  var setter = Object.getOwnPropertyDescriptor(proto, 'value').set
54
50
  setter.call(el, el.value + text)
55
51
  el.dispatchEvent(new Event('input', { bubbles: true }))
@@ -64,7 +60,9 @@ window.__ModuleLoader__.load({
64
60
  .json()
65
61
  .catch(() => ({}))
66
62
  .then((body) => {
67
- throw new Error(body.error || `paste upload failed (${res.status})`)
63
+ var error = new Error(body.error || `paste upload failed (${res.status})`)
64
+ error.status = res.status
65
+ throw error
68
66
  })
69
67
  }
70
68
  return res.json()
@@ -72,14 +70,6 @@ window.__ModuleLoader__.load({
72
70
  )
73
71
  }
74
72
 
75
- // The takeover is for text-only models: the (modlens vision) variants
76
- // convert pastes at request time with the thumbnail preserved, and real
77
- // vision models read images natively — both keep the original paste UX.
78
- // The model selector button's accessible label is the only client-side
79
- // source of the current model; when it cannot be found, taking over is
80
- // the safe default (text-only is the common case this exists for).
81
- var VISION_HINT = /\(modlens vision\)|deepseek-(vl|ocr)|janus|glm-[\d.]*v\b|vision|image/i
82
-
83
73
  function currentModelLabel() {
84
74
  var buttons = document.querySelectorAll('button[aria-label]')
85
75
  for (var i = 0; i < buttons.length; i++) {
@@ -89,10 +79,73 @@ window.__ModuleLoader__.load({
89
79
  return ''
90
80
  }
91
81
 
82
+ // Whether to take a paste over is the HOST's call (GET /modlens/paste
83
+ // with the selector label; the host resolves it against real model
84
+ // metadata). A name regex here once declared every vision model it did
85
+ // not recognize text-only and hijacked its native paste. The verdict is
86
+ // cached per label and refreshed in the background; until a label has a
87
+ // cached `true`, pastes stay native — the safe direction for both a
88
+ // vision model (keeps its thumbnail) and a text-only one (keeps only its
89
+ // old error message, once). A 404 means the route is off (pasteToPath:
90
+ // false, or no host half), so the client stands down entirely instead of
91
+ // swallowing pastes into a dead endpoint.
92
+ var routeAvailable = true
93
+ var verdicts = {}
94
+ // A verdict older than this is UNKNOWN again, even while a refresh is in
95
+ // flight: the route's model metadata can change mid-session (discovery
96
+ // sweeps, provider mounts), and acting on a long-stale `true` is exactly
97
+ // the vision-model hijack this design exists to prevent. The bound is a
98
+ // backstop, since every focus and paste re-asks anyway.
99
+ var VERDICT_MAX_AGE_MS = 60000
100
+
101
+ function refreshVerdict(label) {
102
+ if (!routeAvailable) return
103
+ var cached = verdicts[label]
104
+ // Dedupe only on an in-flight request, never on freshness: the host's
105
+ // model inventory can change under an unchanged label (a same-named
106
+ // route mounting mid-session), so every focus and paste re-asks and a
107
+ // stale answer survives at most one local round-trip.
108
+ if (cached?.pending) return
109
+ var entry = { pending: true, takeover: cached ? cached.takeover : false, at: cached ? cached.at : 0 }
110
+ verdicts[label] = entry
111
+ fetch(`/modlens/paste?model=${encodeURIComponent(label)}`)
112
+ .then((res) => {
113
+ if (res.status === 404) {
114
+ routeAvailable = false
115
+ entry.pending = false
116
+ return null
117
+ }
118
+ if (!res.ok) throw new Error(`policy ${res.status}`)
119
+ return res.json()
120
+ })
121
+ .then((body) => {
122
+ entry.pending = false
123
+ if (body) {
124
+ entry.takeover = body.takeover === true
125
+ entry.at = Date.now()
126
+ }
127
+ })
128
+ .catch(() => {
129
+ entry.pending = false
130
+ })
131
+ }
132
+
133
+ // A paste needs the composer focused first, so a focus-time prefetch has
134
+ // the verdict ready before the first paste can land.
135
+ function onFocusIn() {
136
+ refreshVerdict(currentModelLabel())
137
+ }
138
+
92
139
  function onPaste(event) {
140
+ if (!routeAvailable) return
93
141
  var files = imageFilesOf(event)
94
142
  if (files.length === 0) return
95
- if (VISION_HINT.test(currentModelLabel())) return
143
+ var label = currentModelLabel()
144
+ var cached = verdicts[label]
145
+ refreshVerdict(label)
146
+ // No fresh confirmed host verdict: leave the paste native. Wrong only
147
+ // for a text-only model's very first paste, and self-correcting.
148
+ if (!cached || cached.at === 0 || cached.takeover !== true || Date.now() - cached.at > VERDICT_MAX_AGE_MS) return
96
149
  // Take the paste before the composer's intake starts an attachment (and
97
150
  // with it the host-side image admission a text-only model fails).
98
151
  event.preventDefault()
@@ -107,15 +160,30 @@ window.__ModuleLoader__.load({
107
160
  if (text) insertText(target, `${text} `)
108
161
  })
109
162
  .catch((error) => {
110
- console.error(`[modlens] paste-to-path failed: ${error && error.message ? error.message : error}`)
163
+ // A 404 here means the route vanished AFTER a verdict confirmed it
164
+ // (plugin disposed mid-session): that race can cost this one paste
165
+ // — preventDefault already ran — but never another. Stand down and
166
+ // forget every verdict, so the next paste goes native immediately.
167
+ if (error && error.status === 404) {
168
+ routeAvailable = false
169
+ verdicts = {}
170
+ }
171
+ console.error(`[modlens] paste-to-path failed: ${error?.message ? error.message : error}`)
111
172
  })
112
173
  }
113
174
 
114
175
  function apply(ctx) {
115
176
  document.addEventListener('paste', onPaste, true)
177
+ document.addEventListener('focusin', onFocusIn, true)
116
178
  // cordis effect: unregister on plugin disposal (HMR, profile reload).
117
179
  if (typeof ctx.effect === 'function') {
118
- ctx.effect(() => () => document.removeEventListener('paste', onPaste, true), 'modlens: paste-to-path listener')
180
+ ctx.effect(
181
+ () => () => {
182
+ document.removeEventListener('paste', onPaste, true)
183
+ document.removeEventListener('focusin', onFocusIn, true)
184
+ },
185
+ 'modlens: paste-to-path listener',
186
+ )
119
187
  }
120
188
  }
121
189