dsh-vision-fallback 0.5.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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +329 -0
- package/lib/index.js +551 -0
- package/package.json +65 -0
- package/test/vision-fallback.test.mjs +443 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1HelloMan1
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# dsh-vision-fallback
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Silent vision enhancement for [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness): keep your real text-only main model (e.g. `deepseek-v4-flash`), and let chat images "just work" — every image you drop, paste, or reference in the chat box is automatically sent to a fixed vision model, converted into a factual text observation, and handed to your main model as hidden context. The UI keeps showing your original image; no model groups, no model switching, no extra tools.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
- DeepSeek V4 Flash / Pro and other strong coding models are **text-only**: dropping an image into the chat box fails with "model does not support image input".
|
|
10
|
+
- Existing "vision tool" plugins require saving images as files and invoking a `see_image(path)` tool — clunky, and the main model still can't see chat attachments.
|
|
11
|
+
- This plugin bridges the gap at the request layer, so **chat-box images work exactly like you expect**, regardless of which main model you pick in the model picker.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
You drop/paste an image ──► chat attachment (kept visible in UI)
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
agent/pre-step ──► image + current question + recent context
|
|
20
|
+
│ │
|
|
21
|
+
│ ▼
|
|
22
|
+
│ fixed vision model (OpenAI-compatible /chat/completions)
|
|
23
|
+
│ │ factual text observation
|
|
24
|
+
│ ▼
|
|
25
|
+
└──► model-only surface replacement ──► main model (text only)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
1. The plugin overrides the pre-send capability check, so a text-only model can receive image-bearing messages.
|
|
29
|
+
2. `agent/pre-step` detects images in the incoming turn, and sends the image, the latest user question, and recent conversation context to the configured vision model.
|
|
30
|
+
3. Your original image stays in the UI as a normal chat attachment.
|
|
31
|
+
4. A **model-only surface replacement** swaps the image for the vision observation before the request reaches the main model.
|
|
32
|
+
5. Switching the main model (DeepSeek, Kimi, MiniMax, ...) never changes the fixed vision model.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
### From npm / local checkout
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# npm (if published) or a local checkout directory
|
|
40
|
+
dsh plugin --profile web add dsh-vision-fallback
|
|
41
|
+
# or: dsh plugin --profile web add /path/to/dsh-vision-fallback
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### From source
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
git clone https://github.com/1HelloMan1/dsh-vision-fallback.git
|
|
48
|
+
cd dsh-vision-fallback
|
|
49
|
+
pnpm install --config.minimumReleaseAge=0 # rc.6 peers need the release-age flag bypassed
|
|
50
|
+
pnpm test # 10 unit tests
|
|
51
|
+
dsh plugin --profile web add "$PWD"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then verify and restart:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
dsh --profile web --dump-config # expect a "# == dsh-vision-fallback" layer
|
|
58
|
+
# restart `dsh web` (patch/bundle layers are not hot-reloaded)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> The plugin is also compatible with any profile (headless, TUI) — it registers on the host plane.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
Two ways, both live (no restart needed after saving):
|
|
66
|
+
|
|
67
|
+
### Web settings page
|
|
68
|
+
|
|
69
|
+
Open **Settings → 视觉增强 / Vision Enhancement** in the DSH web UI. It exposes only:
|
|
70
|
+
|
|
71
|
+
| Field | Default | Meaning |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| Enabled | `true` | Master switch |
|
|
74
|
+
| Vision model | `mimo-v2.5` | OpenAI-compatible `model` |
|
|
75
|
+
| Base URL | `https://opencode.ai/zen/go/v1` | The plugin appends `/chat/completions` |
|
|
76
|
+
| Credential ref | `OPENCODE_GO_API_KEY` | Key resolved from the DSH credential store (never written to env files) |
|
|
77
|
+
| Max tokens / Timeout / Max bytes | `1536` / `60000` / `15MB` | Vision request limits |
|
|
78
|
+
| Recent context | `includeRecentContext: true`, `contextMessages: 6`, `contextMaxChars: 6000` | How much recent chat to attach for the vision model |
|
|
79
|
+
| Prompt | (Chinese detailed-analysis prompt) | Analysis instruction; the user's question is appended automatically |
|
|
80
|
+
| Tag result | `true` | Prepend `【视觉观察:<model>】` to the observation |
|
|
81
|
+
|
|
82
|
+
### settings.yaml
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
vision-fallback:
|
|
86
|
+
enabled: true
|
|
87
|
+
model: mimo-v2.5
|
|
88
|
+
baseURL: https://opencode.ai/zen/go/v1
|
|
89
|
+
apiKeyRef: OPENCODE_GO_API_KEY
|
|
90
|
+
maxTokens: 1536
|
|
91
|
+
timeoutMs: 60000
|
|
92
|
+
maxBytes: 15728640
|
|
93
|
+
includeRecentContext: true
|
|
94
|
+
contextMessages: 6
|
|
95
|
+
contextMaxChars: 6000
|
|
96
|
+
prompt: "请分析这张图片..."
|
|
97
|
+
tagResult: true
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The API key is resolved through the DSH **credentials** system (`~/.dsh/.credentials.yaml`), with `process.env[apiKeyRef]` as a fallback — it is never materialized into shell environment files by the plugin.
|
|
101
|
+
|
|
102
|
+
## Security & privacy
|
|
103
|
+
|
|
104
|
+
- The config route is loopback-only and same-origin checked; request bodies are size-limited and schema-validated.
|
|
105
|
+
- The vision model gets **no tools**, **no system prompt**, **no execution permission** — only the image, the question, and recent text context.
|
|
106
|
+
- Observations are delivered as model-only surface replacements; your original image is never altered in the UI.
|
|
107
|
+
- Image reads go through the DSH attachment service (sandbox/observation-policy aware); the vision request carries the official `attributionHeaders()`.
|
|
108
|
+
|
|
109
|
+
## Default vision route
|
|
110
|
+
|
|
111
|
+
- Model: `mimo-v2.5` · Endpoint: `https://opencode.ai/zen/go/v1/chat/completions` · Credential: `OPENCODE_GO_API_KEY`
|
|
112
|
+
|
|
113
|
+
Any OpenAI-compatible vision endpoint works (Zhipu GLM-4V-Flash, SiliconFlow Qwen-VL, vLLM, Ollama, ...) — just change `model`, `baseURL`, and `apiKeyRef` in the settings page.
|
|
114
|
+
|
|
115
|
+
## Relationship to the OpenCode ecosystem
|
|
116
|
+
|
|
117
|
+
The OpenCode community `opencode-see-image` hands a `filePath` + task `question` to a fixed vision model and returns text to the main model. DSH additionally performs a pre-send image-capability check, which this plugin also overrides, using DSH's official `agent/pre-step` and model-only surface replacement to keep the UI silent.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
pnpm test # node --test test/*.test.mjs — 10 tests
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Structure:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
dsh-vision-fallback/
|
|
129
|
+
├── package.json # dsh.bundle + dsh.client manifests
|
|
130
|
+
├── cordis.patch.yml # inserts the vision-fallback row
|
|
131
|
+
├── lib/index.js # host plugin (pre-step bridge, config route, controller)
|
|
132
|
+
├── lib/client.js # Web settings page ("视觉增强")
|
|
133
|
+
└── test/ # unit tests
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# dsh-vision-fallback
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
为 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 提供**全局静默视觉增强**:你继续在会话顶部直接选择真实主模型(如 `deepseek-v4-flash`),聊天框里拖入、粘贴、引用的图片会自动交给固定视觉模型,转成事实性文字观察后作为隐藏上下文交给主模型。界面始终显示你的原始图片——不新增"视觉回退"模型分组、不切换模型、不调用额外工具。
|
|
6
|
+
|
|
7
|
+
## 为什么需要
|
|
8
|
+
|
|
9
|
+
- DeepSeek V4 Flash/Pro 等强编码模型是**纯文本**的:聊天框发图会直接报"模型不支持图片输入"。
|
|
10
|
+
- 已有的"视觉工具"类插件需要你把图存成文件再调 `see_image(path)`——笨重,而且主模型依然收不到聊天框附件。
|
|
11
|
+
- 本插件在请求层架桥:**聊天框发图就像原生支持一样**,无论你在模型选择器里选哪个主模型。
|
|
12
|
+
|
|
13
|
+
## 工作方式
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
拖图/粘贴 ──► 聊天附件(UI 始终显示原图)
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
agent/pre-step ──► 图片 + 当前问题 + 最近上下文
|
|
20
|
+
│ │
|
|
21
|
+
│ ▼
|
|
22
|
+
│ 固定视觉模型(OpenAI 兼容 /chat/completions)
|
|
23
|
+
│ │ 事实性文字观察
|
|
24
|
+
│ ▼
|
|
25
|
+
└──► model-only surface replacement ──► 主模型(纯文本)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
1. 插件覆盖发送前的图片能力检查,让文本主模型也能接收带图消息。
|
|
29
|
+
2. `agent/pre-step` 检测本轮图片,把图片、最新用户问题和最近对话上下文发给配置的视觉模型。
|
|
30
|
+
3. 你的原始图片作为正常聊天附件保留在界面。
|
|
31
|
+
4. **仅模型可见的 surface replacement** 把图片换成视觉观察后再交给主模型。
|
|
32
|
+
5. 切换主模型(DeepSeek、Kimi、MiniMax……)不会改变固定视觉模型。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
### npm / 本地检出
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# npm 发布后(或本地检出目录)
|
|
40
|
+
dsh plugin --profile web add dsh-vision-fallback
|
|
41
|
+
# 或:dsh plugin --profile web add /path/to/dsh-vision-fallback
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 从源码
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
git clone https://github.com/1HelloMan1/dsh-vision-fallback.git
|
|
48
|
+
cd dsh-vision-fallback
|
|
49
|
+
pnpm install --config.minimumReleaseAge=0 # rc.6 peer 依赖需绕过发布年龄策略
|
|
50
|
+
pnpm test # 10 个单元测试
|
|
51
|
+
dsh plugin --profile web add "$PWD"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
然后验证并重启:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
dsh --profile web --dump-config # 应出现 "# == dsh-vision-fallback" 层
|
|
58
|
+
# 重启 `dsh web`(patch/bundle 层不支持热加载)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> 插件注册在 host 平面,headless / TUI 等其他 profile 同样兼容。
|
|
62
|
+
|
|
63
|
+
## 配置
|
|
64
|
+
|
|
65
|
+
两种方式都实时生效(保存后无需重启):
|
|
66
|
+
|
|
67
|
+
### Web 设置页
|
|
68
|
+
|
|
69
|
+
打开 DSH Web **设置 → 视觉增强**,只暴露这些项:
|
|
70
|
+
|
|
71
|
+
| 字段 | 默认值 | 说明 |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| 启用 | `true` | 总开关 |
|
|
74
|
+
| 视觉模型 | `mimo-v2.5` | OpenAI 兼容 `model` |
|
|
75
|
+
| API 地址 | `https://opencode.ai/zen/go/v1` | 插件自动拼 `/chat/completions` |
|
|
76
|
+
| 凭据引用 | `OPENCODE_GO_API_KEY` | 从 DSH 凭证系统解析(不写入环境变量) |
|
|
77
|
+
| 输出上限 / 超时 / 图片上限 | `1536` / `60000` / `15MB` | 视觉请求限制 |
|
|
78
|
+
| 最近上下文 | `includeRecentContext: true`、`contextMessages: 6`、`contextMaxChars: 6000` | 附带多少最近对话给视觉模型 |
|
|
79
|
+
| 提示词 | (中文详细分析) | 分析指令;用户问题自动追加 |
|
|
80
|
+
| 结果标注 | `true` | 观察前加 `【视觉观察:<model>】` |
|
|
81
|
+
|
|
82
|
+
### settings.yaml
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
vision-fallback:
|
|
86
|
+
enabled: true
|
|
87
|
+
model: mimo-v2.5
|
|
88
|
+
baseURL: https://opencode.ai/zen/go/v1
|
|
89
|
+
apiKeyRef: OPENCODE_GO_API_KEY
|
|
90
|
+
maxTokens: 1536
|
|
91
|
+
timeoutMs: 60000
|
|
92
|
+
maxBytes: 15728640
|
|
93
|
+
includeRecentContext: true
|
|
94
|
+
contextMessages: 6
|
|
95
|
+
contextMaxChars: 6000
|
|
96
|
+
prompt: "请分析这张图片..."
|
|
97
|
+
tagResult: true
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
API key 通过 DSH **凭证系统**(`~/.dsh/.credentials.yaml`)解析,`process.env[apiKeyRef]` 作为兜底——插件不会把 key 写入 shell 环境文件。
|
|
101
|
+
|
|
102
|
+
## 安全与隐私
|
|
103
|
+
|
|
104
|
+
- 配置路由仅限本机回环 + 同源校验,请求体限大小并做 schema 校验。
|
|
105
|
+
- 视觉模型**没有工具权限、没有 system prompt、没有执行权限**——只拿到图片、问题和最近文字上下文。
|
|
106
|
+
- 观察结果以 model-only surface replacement 交付,界面中的原始图片永不被改写。
|
|
107
|
+
- 图片读取走 DSH 附件服务(遵守沙箱与观察策略);视觉请求携带官方 `attributionHeaders()`。
|
|
108
|
+
|
|
109
|
+
## 默认视觉路由
|
|
110
|
+
|
|
111
|
+
- 模型:`mimo-v2.5` · 端点:`https://opencode.ai/zen/go/v1/chat/completions` · 凭据:`OPENCODE_GO_API_KEY`
|
|
112
|
+
|
|
113
|
+
任何 OpenAI 兼容视觉端点都可用(智谱 GLM-4V-Flash、SiliconFlow Qwen-VL、vLLM、Ollama……),在设置页改 `model`、`baseURL`、`apiKeyRef` 即可。
|
|
114
|
+
|
|
115
|
+
## 与 OpenCode 方案的关系
|
|
116
|
+
|
|
117
|
+
OpenCode 社区的 `opencode-see-image` 把 `filePath` 和针对当前任务的 `question` 交给固定视觉模型,再把文字结果返回当前主模型。DSH 比 OpenCode 多了一层发送前的图片能力校验,因此这里同时覆盖该校验,并用 DSH 官方的 `agent/pre-step` 与 model-only surface replacement 保持界面静默。
|
|
118
|
+
|
|
119
|
+
## 开发
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
pnpm test # node --test test/*.test.mjs — 10 个测试
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
目录结构:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
dsh-vision-fallback/
|
|
129
|
+
├── package.json # dsh.bundle + dsh.client 声明
|
|
130
|
+
├── cordis.patch.yml # 插入 vision-fallback 行
|
|
131
|
+
├── lib/index.js # host 插件(pre-step 桥、配置路由、控制器)
|
|
132
|
+
├── lib/client.js # Web 设置页("视觉增强")
|
|
133
|
+
└── test/ # 单元测试
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 许可证
|
|
137
|
+
|
|
138
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-vision-fallback",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
const react = require("react");
|
|
8
|
+
|
|
9
|
+
const NS = "vision-fallback";
|
|
10
|
+
const CONFIG_ROUTE = "/plugins/dsh-vision-fallback/config";
|
|
11
|
+
const CUSTOM_MODEL_VALUE = "__custom__";
|
|
12
|
+
const VISION_MODEL_PRESETS = [
|
|
13
|
+
{ id: "mimo-v2.5", name: "MiMo V2.5" }
|
|
14
|
+
];
|
|
15
|
+
const EDITABLE_FIELDS = [
|
|
16
|
+
"enabled",
|
|
17
|
+
"model",
|
|
18
|
+
"baseURL",
|
|
19
|
+
"apiKeyRef",
|
|
20
|
+
"maxTokens",
|
|
21
|
+
"timeoutMs",
|
|
22
|
+
"maxBytes",
|
|
23
|
+
"includeRecentContext",
|
|
24
|
+
"contextMessages",
|
|
25
|
+
"contextMaxChars",
|
|
26
|
+
"prompt",
|
|
27
|
+
"tagResult"
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const css = `
|
|
31
|
+
.vf-page{max-width:900px;padding:8px 0 40px;color:var(--dsw-alias-label-primary)}
|
|
32
|
+
.vf-head{margin-bottom:22px}.vf-head h2{margin:0 0 8px;font-size:22px}.vf-head p{margin:0;color:var(--dsw-alias-label-secondary);line-height:1.6}
|
|
33
|
+
.vf-card{border:1px solid var(--dsw-alias-stroke-secondary);border-radius:16px;background:var(--dsw-alias-bg-base);padding:20px;display:grid;gap:18px}
|
|
34
|
+
.vf-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.vf-field{display:grid;gap:7px;min-width:0}.vf-field-wide{grid-column:1/-1}
|
|
35
|
+
.vf-label{font-size:13px;font-weight:600}.vf-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}
|
|
36
|
+
.vf-input,.vf-select,.vf-textarea{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-stroke-secondary);border-radius:10px;background:var(--dsw-alias-bg-elevated);color:var(--dsw-alias-label-primary);font:inherit;padding:10px 12px;outline:none}
|
|
37
|
+
.vf-input:focus,.vf-select:focus,.vf-textarea:focus{border-color:var(--dsw-alias-stroke-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-stroke-primary) 22%,transparent)}
|
|
38
|
+
.vf-model-picker{display:grid;gap:8px}
|
|
39
|
+
.vf-textarea{min-height:132px;resize:vertical;line-height:1.55}.vf-check{display:flex;align-items:center;gap:9px;font-size:13px}.vf-check input{width:16px;height:16px}
|
|
40
|
+
.vf-actions{display:flex;justify-content:flex-end;align-items:center;gap:10px;padding-top:2px}.vf-button{border:1px solid var(--dsw-alias-stroke-secondary);border-radius:999px;background:transparent;color:var(--dsw-alias-label-primary);padding:9px 18px;font:inherit;cursor:pointer}.vf-button-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base);border-color:transparent}.vf-button:disabled{opacity:.45;cursor:not-allowed}
|
|
41
|
+
.vf-status{border-radius:10px;padding:10px 12px;font-size:13px;line-height:1.5}.vf-status-warn{background:color-mix(in srgb,#f59e0b 12%,transparent);color:#d9971a}.vf-status-error{background:color-mix(in srgb,#ef4444 12%,transparent);color:#e05a5a}.vf-status-ok{background:color-mix(in srgb,#22c55e 12%,transparent);color:#31a85c}
|
|
42
|
+
.vf-advanced{border-top:1px solid var(--dsw-alias-stroke-secondary);padding-top:16px}.vf-advanced summary{cursor:pointer;font-weight:600;font-size:13px}.vf-advanced .vf-grid{margin-top:16px}
|
|
43
|
+
@media(max-width:760px){.vf-grid{grid-template-columns:1fr}.vf-field-wide{grid-column:auto}.vf-card{padding:16px}}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
function sameValue(left, right) {
|
|
47
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function snapshotOf(scope) {
|
|
51
|
+
return react.useSyncExternalStore(
|
|
52
|
+
(listener) => scope.subscribe(listener),
|
|
53
|
+
() => scope.getSnapshot(),
|
|
54
|
+
() => scope.getSnapshot()
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createConfigScope() {
|
|
59
|
+
let snapshot = {
|
|
60
|
+
status: "loading",
|
|
61
|
+
value: undefined,
|
|
62
|
+
revision: 0,
|
|
63
|
+
writable: false,
|
|
64
|
+
error: ""
|
|
65
|
+
};
|
|
66
|
+
const listeners = new Set();
|
|
67
|
+
let disposed = false;
|
|
68
|
+
|
|
69
|
+
const publish = (next) => {
|
|
70
|
+
if (disposed) return;
|
|
71
|
+
snapshot = next;
|
|
72
|
+
for (const listener of listeners) listener();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const request = async (options) => {
|
|
76
|
+
const response = await fetch(CONFIG_ROUTE, {
|
|
77
|
+
cache: "no-store",
|
|
78
|
+
credentials: "same-origin",
|
|
79
|
+
...options
|
|
80
|
+
});
|
|
81
|
+
const body = await response.json();
|
|
82
|
+
if (!response.ok || body.ok !== true) throw new Error(body?.error?.message || `配置请求失败:HTTP ${response.status}`);
|
|
83
|
+
return body.value;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const load = async () => {
|
|
87
|
+
publish({ ...snapshot, status: "loading", error: "" });
|
|
88
|
+
try {
|
|
89
|
+
const value = await request({ method: "GET" });
|
|
90
|
+
publish({ status: "ready", value, revision: snapshot.revision + 1, writable: true, error: "" });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
publish({ status: "unavailable", value: undefined, revision: snapshot.revision + 1, writable: false, error: error instanceof Error ? error.message : String(error) });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const replace = async (value) => {
|
|
97
|
+
const resolved = await request({
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "content-type": "application/json" },
|
|
100
|
+
body: JSON.stringify(value)
|
|
101
|
+
});
|
|
102
|
+
publish({ status: "ready", value: resolved, revision: snapshot.revision + 1, writable: true, error: "" });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
void load();
|
|
106
|
+
return {
|
|
107
|
+
getSnapshot: () => snapshot,
|
|
108
|
+
subscribe(listener) {
|
|
109
|
+
listeners.add(listener);
|
|
110
|
+
return () => listeners.delete(listener);
|
|
111
|
+
},
|
|
112
|
+
load,
|
|
113
|
+
replace,
|
|
114
|
+
dispose() {
|
|
115
|
+
disposed = true;
|
|
116
|
+
listeners.clear();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function field(label, control, hint, wide = false) {
|
|
122
|
+
return react.createElement(
|
|
123
|
+
"label",
|
|
124
|
+
{ className: `vf-field${wide ? " vf-field-wide" : ""}` },
|
|
125
|
+
react.createElement("span", { className: "vf-label" }, label),
|
|
126
|
+
control,
|
|
127
|
+
hint ? react.createElement("span", { className: "vf-hint" }, hint) : null
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function option(value, label) {
|
|
132
|
+
return react.createElement("option", { key: value, value }, label);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function VisionFallbackSettings({ scope, api }) {
|
|
136
|
+
const snapshot = snapshotOf(scope);
|
|
137
|
+
const [draft, setDraft] = react.useState(() => ({ ...(snapshot.value || {}) }));
|
|
138
|
+
const [groups, setGroups] = react.useState([]);
|
|
139
|
+
const [catalogState, setCatalogState] = react.useState("loading");
|
|
140
|
+
const [saving, setSaving] = react.useState(false);
|
|
141
|
+
const [failure, setFailure] = react.useState("");
|
|
142
|
+
const [saved, setSaved] = react.useState(false);
|
|
143
|
+
|
|
144
|
+
react.useEffect(() => {
|
|
145
|
+
if (snapshot.status !== "ready" || snapshot.value === undefined) return;
|
|
146
|
+
setDraft({ ...snapshot.value });
|
|
147
|
+
}, [snapshot.status, snapshot.revision]);
|
|
148
|
+
|
|
149
|
+
const loadModels = react.useCallback(async () => {
|
|
150
|
+
setCatalogState("loading");
|
|
151
|
+
try {
|
|
152
|
+
const response = await api.llm.models({});
|
|
153
|
+
if (!response.result.ok) throw new Error(response.result.error.message);
|
|
154
|
+
setGroups(response.result.value.groups || []);
|
|
155
|
+
setCatalogState("ready");
|
|
156
|
+
} catch {
|
|
157
|
+
setCatalogState("error");
|
|
158
|
+
}
|
|
159
|
+
}, [api]);
|
|
160
|
+
|
|
161
|
+
react.useEffect(() => {
|
|
162
|
+
loadModels();
|
|
163
|
+
}, [loadModels]);
|
|
164
|
+
|
|
165
|
+
const update = (key, value) => {
|
|
166
|
+
setSaved(false);
|
|
167
|
+
setFailure("");
|
|
168
|
+
setDraft((current) => ({ ...current, [key]: value }));
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const preferredVisionGroup = groups.find((group) => group.id === "opencode-go");
|
|
172
|
+
const visionModels = preferredVisionGroup?.models || groups.flatMap((group) => group.id === "vision-fallback" ? [] : group.models || []);
|
|
173
|
+
const uniqueVisionModels = [...new Map([...VISION_MODEL_PRESETS, ...visionModels].map((model) => [model.id, model])).values()];
|
|
174
|
+
const selectedVisionModel = uniqueVisionModels.some((model) => model.id === draft.model) ? draft.model : CUSTOM_MODEL_VALUE;
|
|
175
|
+
const current = snapshot.value || {};
|
|
176
|
+
const dirty = EDITABLE_FIELDS.some((key) => !sameValue(current[key], draft[key]));
|
|
177
|
+
const writable = snapshot.status === "ready" && snapshot.writable;
|
|
178
|
+
|
|
179
|
+
const save = async () => {
|
|
180
|
+
if (!writable || saving || !dirty) return;
|
|
181
|
+
setSaving(true);
|
|
182
|
+
setFailure("");
|
|
183
|
+
setSaved(false);
|
|
184
|
+
const desired = { ...draft };
|
|
185
|
+
try {
|
|
186
|
+
await scope.replace(desired);
|
|
187
|
+
setSaved(true);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
setFailure(error instanceof Error ? error.message : String(error));
|
|
190
|
+
} finally {
|
|
191
|
+
setSaving(false);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const reset = () => {
|
|
196
|
+
setDraft({ ...(snapshot.value || {}) });
|
|
197
|
+
setFailure("");
|
|
198
|
+
setSaved(false);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (snapshot.status === "loading") {
|
|
202
|
+
return react.createElement("section", { className: "vf-page" }, react.createElement("p", null, "正在加载视觉增强配置…"));
|
|
203
|
+
}
|
|
204
|
+
if (snapshot.status === "unavailable") {
|
|
205
|
+
return react.createElement(
|
|
206
|
+
"section",
|
|
207
|
+
{ className: "vf-page" },
|
|
208
|
+
react.createElement("div", { className: "vf-status vf-status-error" }, snapshot.error || "视觉增强配置暂不可用。"),
|
|
209
|
+
react.createElement("div", { className: "vf-actions" }, react.createElement("button", { className: "vf-button", type: "button", onClick: scope.load }, "重新加载"))
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return react.createElement(
|
|
214
|
+
"section",
|
|
215
|
+
{ className: "vf-page" },
|
|
216
|
+
react.createElement(
|
|
217
|
+
"header",
|
|
218
|
+
{ className: "vf-head" },
|
|
219
|
+
react.createElement("h2", null, "静默视觉增强"),
|
|
220
|
+
react.createElement("p", null, "主模型照常选择。检测到图片时,后台固定使用这里配置的视觉模型,再把观察结果交给当前主模型。")
|
|
221
|
+
),
|
|
222
|
+
react.createElement(
|
|
223
|
+
"div",
|
|
224
|
+
{ className: "vf-card" },
|
|
225
|
+
catalogState === "error" ? react.createElement("div", { className: "vf-status vf-status-warn" }, "模型目录读取失败,仍可手动填写视觉模型名称。") : null,
|
|
226
|
+
saved ? react.createElement("div", { className: "vf-status vf-status-ok" }, "配置已保存并立即生效。") : null,
|
|
227
|
+
failure ? react.createElement("div", { className: "vf-status vf-status-error" }, failure) : null,
|
|
228
|
+
react.createElement("div", { className: "vf-status vf-status-ok" }, "开启后不会新增模型分组,也不需要切换到特殊模型。会话顶部仍然直接选择 DeepSeek、Kimi、MiniMax 等主模型。"),
|
|
229
|
+
react.createElement(
|
|
230
|
+
"div",
|
|
231
|
+
{ className: "vf-grid" },
|
|
232
|
+
react.createElement(
|
|
233
|
+
"label",
|
|
234
|
+
{ className: "vf-check vf-field-wide" },
|
|
235
|
+
react.createElement("input", {
|
|
236
|
+
type: "checkbox",
|
|
237
|
+
checked: draft.enabled !== false,
|
|
238
|
+
disabled: !writable,
|
|
239
|
+
onChange: (event) => update("enabled", event.target.checked)
|
|
240
|
+
}),
|
|
241
|
+
react.createElement("span", null, "启用静默视觉增强")
|
|
242
|
+
),
|
|
243
|
+
field(
|
|
244
|
+
"固定视觉模型",
|
|
245
|
+
react.createElement(
|
|
246
|
+
"div",
|
|
247
|
+
{ className: "vf-model-picker" },
|
|
248
|
+
react.createElement(
|
|
249
|
+
"select",
|
|
250
|
+
{
|
|
251
|
+
className: "vf-select",
|
|
252
|
+
value: selectedVisionModel,
|
|
253
|
+
disabled: !writable,
|
|
254
|
+
onChange: (event) => update("model", event.target.value === CUSTOM_MODEL_VALUE ? "" : event.target.value)
|
|
255
|
+
},
|
|
256
|
+
uniqueVisionModels.map((model) => option(model.id, model.name && model.name !== model.id ? `${model.name} · ${model.id}` : model.id)),
|
|
257
|
+
option(CUSTOM_MODEL_VALUE, "手动输入其他模型…")
|
|
258
|
+
),
|
|
259
|
+
selectedVisionModel === CUSTOM_MODEL_VALUE ? react.createElement("input", {
|
|
260
|
+
className: "vf-input",
|
|
261
|
+
value: draft.model || "",
|
|
262
|
+
placeholder: "输入兼容视觉模型名称",
|
|
263
|
+
disabled: !writable,
|
|
264
|
+
onChange: (event) => update("model", event.target.value)
|
|
265
|
+
}) : null
|
|
266
|
+
),
|
|
267
|
+
preferredVisionGroup ? "固定使用这里选择的模型识别图片,不随会话主模型切换。选项来自 opencode-go 模型目录。" : "固定使用这里选择的模型识别图片,不随会话主模型切换。在线目录不可用时仍可选择预设或手动输入。",
|
|
268
|
+
true
|
|
269
|
+
)
|
|
270
|
+
),
|
|
271
|
+
react.createElement(
|
|
272
|
+
"details",
|
|
273
|
+
{ className: "vf-advanced" },
|
|
274
|
+
react.createElement("summary", null, "高级设置"),
|
|
275
|
+
react.createElement(
|
|
276
|
+
"div",
|
|
277
|
+
{ className: "vf-grid" },
|
|
278
|
+
field("视觉 API 地址", react.createElement("input", { className: "vf-input", value: draft.baseURL || "", disabled: !writable, onChange: (event) => update("baseURL", event.target.value) }), "OpenAI Chat Completions 兼容地址。", true),
|
|
279
|
+
field("凭据引用", react.createElement("input", { className: "vf-input", value: draft.apiKeyRef || "", disabled: !writable, onChange: (event) => update("apiKeyRef", event.target.value) }), "例如 OPENCODE_GO_API_KEY。"),
|
|
280
|
+
field("视觉输出上限", react.createElement("input", { className: "vf-input", type: "number", min: 1, value: draft.maxTokens ?? 1536, disabled: !writable, onChange: (event) => update("maxTokens", Number(event.target.value)) }), "视觉观察最多生成多少 token。"),
|
|
281
|
+
field("请求超时(毫秒)", react.createElement("input", { className: "vf-input", type: "number", min: 1, value: draft.timeoutMs ?? 60000, disabled: !writable, onChange: (event) => update("timeoutMs", Number(event.target.value)) }), "视觉 API 超时后降级为失败提示。"),
|
|
282
|
+
field("最近上下文条数", react.createElement("input", { className: "vf-input", type: "number", min: 0, value: draft.contextMessages ?? 6, disabled: !writable, onChange: (event) => update("contextMessages", Number(event.target.value)) }), "传给视觉模型的最近用户/助手消息数量。"),
|
|
283
|
+
field("上下文字符上限", react.createElement("input", { className: "vf-input", type: "number", min: 0, value: draft.contextMaxChars ?? 6000, disabled: !writable, onChange: (event) => update("contextMaxChars", Number(event.target.value)) }), "避免视觉提示词无限增长。"),
|
|
284
|
+
react.createElement("label", { className: "vf-check" }, react.createElement("input", { type: "checkbox", checked: draft.includeRecentContext !== false, disabled: !writable, onChange: (event) => update("includeRecentContext", event.target.checked) }), react.createElement("span", null, "携带最近对话上下文")),
|
|
285
|
+
react.createElement("label", { className: "vf-check" }, react.createElement("input", { type: "checkbox", checked: draft.tagResult !== false, disabled: !writable, onChange: (event) => update("tagResult", event.target.checked) }), react.createElement("span", null, "在结果中标记视觉模型名称")),
|
|
286
|
+
field("视觉分析提示词", react.createElement("textarea", { className: "vf-textarea", value: draft.prompt || "", disabled: !writable, onChange: (event) => update("prompt", event.target.value) }), "当前问题、最近上下文和图片信息会自动追加。", true)
|
|
287
|
+
)
|
|
288
|
+
),
|
|
289
|
+
!snapshot.writable ? react.createElement("div", { className: "vf-status vf-status-warn" }, "当前配置文件为只读,无法从页面保存。") : null,
|
|
290
|
+
react.createElement(
|
|
291
|
+
"div",
|
|
292
|
+
{ className: "vf-actions" },
|
|
293
|
+
react.createElement("button", { className: "vf-button", type: "button", disabled: !dirty || saving, onClick: reset }, "放弃修改"),
|
|
294
|
+
react.createElement("button", { className: "vf-button", type: "button", disabled: catalogState === "loading", onClick: loadModels }, "刷新模型列表"),
|
|
295
|
+
react.createElement("button", { className: "vf-button vf-button-primary", type: "button", disabled: !writable || !dirty || saving, onClick: save }, saving ? "保存中…" : "保存")
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const inject = ["slots", "connection"];
|
|
302
|
+
|
|
303
|
+
function apply(ctx) {
|
|
304
|
+
const scope = createConfigScope();
|
|
305
|
+
const { api } = ctx.get("connection");
|
|
306
|
+
ctx.effect(() => () => scope.dispose(), "vision-fallback: 释放配置客户端");
|
|
307
|
+
ctx.effect(() => {
|
|
308
|
+
const id = "dsh-vision-fallback-settings-style";
|
|
309
|
+
if (document.getElementById(id)) return;
|
|
310
|
+
const style = document.createElement("style");
|
|
311
|
+
style.id = id;
|
|
312
|
+
style.textContent = css;
|
|
313
|
+
document.head.append(style);
|
|
314
|
+
return () => style.remove();
|
|
315
|
+
}, "vision-fallback: 设置页样式");
|
|
316
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
317
|
+
name: "settings.section",
|
|
318
|
+
id: "vision-fallback",
|
|
319
|
+
order: 12,
|
|
320
|
+
label: () => "视觉增强",
|
|
321
|
+
inject: () => ({ scope, api })
|
|
322
|
+
}, VisionFallbackSettings));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
exports.apply = apply;
|
|
326
|
+
exports.inject = inject;
|
|
327
|
+
return module.exports;
|
|
328
|
+
}
|
|
329
|
+
});
|