@floatingsk/dsh-vision 0.1.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 +176 -0
- package/bin/vision-ocr.ps1 +99 -0
- package/bin/vision-ocr.swift +103 -0
- package/lib/core.js +102 -0
- package/lib/index.js +230 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 floatingsk
|
|
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,176 @@
|
|
|
1
|
+
# dsh-vision
|
|
2
|
+
|
|
3
|
+
> 给 DeepSeek Harness(dsh)补上视觉能力的插件:**本地 OCR + 云端 VLM 图片理解**。
|
|
4
|
+
|
|
5
|
+
DeepSeek 的模型 API 目前不支持图像输入,`read_image` 因此无法使用。本插件提供两个工具绕过这个限制:
|
|
6
|
+
|
|
7
|
+
| 工具 | 能力 | 成本 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `read_image_text` | 识别图片中的**文字**(macOS 本地 Vision 引擎,免费离线,中英文) | 免费 |
|
|
10
|
+
| `describe_image` | 理解图片的**画面内容**(云端 VLM,OpenAI 兼容端点) | 按量付费 |
|
|
11
|
+
|
|
12
|
+
## 特性
|
|
13
|
+
|
|
14
|
+
- 🔒 **本地 OCR**:基于 macOS Vision.framework,图片不出本机,隐私安全
|
|
15
|
+
- ☁️ **云端 VLM**:默认阿里云百炼 `qwen3-vl-flash`(快且便宜),OpenAI 兼容接口,可换成任意提供商
|
|
16
|
+
- 🖼️ **自动压缩**:VLM 调用前用 `sips` 把大图压到 2048px / JPEG 85%,省钱省流量
|
|
17
|
+
- 🔑 **灵活取 Key**:环境变量或 `~/.dsh/.credentials.yaml`
|
|
18
|
+
- 🧪 **零依赖单测**:核心逻辑用 Node 内置 `node:test` 覆盖(13 用例)
|
|
19
|
+
- 🌏 **多供应商 VLM**:内置百炼 / 硅基流动 / 智谱 / 火山方舟,OpenAI 兼容可加任意家
|
|
20
|
+
- 🪟 **Windows 支持**:附 PowerShell OCR 后端(Windows.Media.Ocr),VLM 通道跨平台
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
### 方式 A:从 npm 安装(发布后推荐)
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
dsh plugin --profile web add @floatingsk/dsh-vision
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 方式 B:从源码拷贝
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
# 把本仓库拷贝到你的 dsh profile 插件目录
|
|
34
|
+
cp -R dsh-vision ~/.dsh/profiles/node_modules/dsh-vision
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 方式 C:从 GitHub Release 下载预编译二进制(免编译,推荐)
|
|
38
|
+
|
|
39
|
+
维护者在打 `v*` tag 时,GitHub Actions 会自动在两种 macOS 架构上编译并附到 Release:
|
|
40
|
+
|
|
41
|
+
1. 打开本仓库的 **Releases** 页面,选择最新版本
|
|
42
|
+
2. 按你的 Mac 架构下载:
|
|
43
|
+
- Apple Silicon(M 系列):`vision-ocr-arm64`
|
|
44
|
+
- Intel Mac:`vision-ocr-x86_64`
|
|
45
|
+
3. 放到插件目录并加执行权限:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
cp vision-ocr-arm64 ~/.dsh/profiles/node_modules/dsh-vision/bin/vision-ocr
|
|
49
|
+
chmod +x ~/.dsh/profiles/node_modules/dsh-vision/bin/vision-ocr
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 编译 OCR 二进制(macOS 需要 Xcode Command Line Tools)
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
cd ~/.dsh/profiles/node_modules/dsh-vision
|
|
56
|
+
# 显式指定 clang 模块缓存目录(沙箱/受限环境下必需)
|
|
57
|
+
swiftc -Xcc -fmodules-cache-path="$PWD/.cache" -O bin/vision-ocr.swift -o bin/vision-ocr
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 在 profile patch 中启用插件
|
|
61
|
+
|
|
62
|
+
编辑 `~/.dsh/profiles/web/cordis.patch.yml`(你的 profile 对应文件),追加:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
- insert:
|
|
66
|
+
- id: dsh-vision
|
|
67
|
+
name: 'dsh-vision'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 配置 VLM API Key(`describe_image` 需要)
|
|
71
|
+
|
|
72
|
+
任选其一:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
# 方式 A:环境变量
|
|
76
|
+
export DASHSCOPE_API_KEY=sk-xxx
|
|
77
|
+
|
|
78
|
+
# 方式 B:写入 dsh 凭据文件
|
|
79
|
+
echo 'DASHSCOPE_API_KEY: sk-xxx' >> ~/.dsh/.credentials.yaml
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Key 从你的 VLM 提供商控制台获取(默认阿里云百炼:[bailian.console.aliyun.com](https://bailian.console.aliyun.com))。
|
|
83
|
+
|
|
84
|
+
### 重启 dsh
|
|
85
|
+
|
|
86
|
+
重启后工具即可用。**注意:需要新开一个对话**,工具列表在会话开始时注入。
|
|
87
|
+
|
|
88
|
+
## 使用
|
|
89
|
+
|
|
90
|
+
在对话中把图片保存到磁盘,告诉 agent 路径即可:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
看下 /path/to/image.png 里有什么
|
|
94
|
+
读取 /path/to/截图.png 中的文字
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Agent 会自动选择合适的工具(读文字走 OCR,看画面走 VLM)。想指定 VLM 供应商时,可让 agent 传 `provider` 参数(如 `bailian` / `siliconflow` / `zhipu` / `volcengine`)。
|
|
98
|
+
|
|
99
|
+
## 配置
|
|
100
|
+
|
|
101
|
+
通过 `cordis.patch.yml` 中 `dsh-vision` 节点的 `config` 覆盖默认值。
|
|
102
|
+
|
|
103
|
+
### 多供应商 VLM
|
|
104
|
+
|
|
105
|
+
内置四家国内供应商,`describe_image` 可传 `provider` 参数选择(留空用 `defaultProvider`):
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
- insert:
|
|
109
|
+
- id: dsh-vision
|
|
110
|
+
name: 'dsh-vision'
|
|
111
|
+
config:
|
|
112
|
+
defaultProvider: 'bailian' # 默认供应商
|
|
113
|
+
providers:
|
|
114
|
+
bailian: # 阿里云百炼
|
|
115
|
+
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
|
116
|
+
model: 'qwen3-vl-flash' # 或 qwen3-vl-plus / qwen-vl-ocr
|
|
117
|
+
apiKeyEnv: 'DASHSCOPE_API_KEY'
|
|
118
|
+
siliconflow: # 硅基流动
|
|
119
|
+
baseUrl: 'https://api.siliconflow.cn/v1'
|
|
120
|
+
model: 'Qwen/Qwen2.5-VL-7B-Instruct'
|
|
121
|
+
apiKeyEnv: 'SILICONFLOW_API_KEY'
|
|
122
|
+
zhipu: # 智谱
|
|
123
|
+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4'
|
|
124
|
+
model: 'glm-4v-flash'
|
|
125
|
+
apiKeyEnv: 'ZHIPU_API_KEY'
|
|
126
|
+
volcengine: # 火山方舟(豆包)
|
|
127
|
+
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3'
|
|
128
|
+
model: 'doubao-seed-1.6-vision'
|
|
129
|
+
apiKeyEnv: 'ARK_API_KEY'
|
|
130
|
+
# 自定义 OCR 二进制路径(默认插件 bin/vision-ocr)
|
|
131
|
+
ocrBin: ''
|
|
132
|
+
# 上传前压缩最长边(像素)
|
|
133
|
+
vlmMaxImageDim: 2048
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
换供应商:改 `defaultProvider`,或在调用时指定 `provider` 参数;加新供应商:在 `providers` 下加任意键名(任何 OpenAI 兼容端点都行)。
|
|
137
|
+
|
|
138
|
+
### 推荐的视觉模型(阿里云百炼)
|
|
139
|
+
|
|
140
|
+
| 模型 | 特点 |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `qwen3-vl-flash`(默认) | 快、便宜,日常够用 |
|
|
143
|
+
| `qwen3-vl-plus` | 质量更高,稍慢稍贵 |
|
|
144
|
+
| `qwen-vl-ocr` | 纯文字识别专用,比本地 OCR 更强(需联网) |
|
|
145
|
+
|
|
146
|
+
## 开发
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
# 运行单元测试
|
|
150
|
+
node --test test/
|
|
151
|
+
|
|
152
|
+
# 重新编译 OCR 二进制
|
|
153
|
+
swiftc -Xcc -fmodules-cache-path="$PWD/.cache" -O bin/vision-ocr.swift -o bin/vision-ocr
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## 平台支持
|
|
157
|
+
|
|
158
|
+
| 能力 | macOS (Apple Silicon) | macOS (Intel) | Windows |
|
|
159
|
+
|---|---|---|---|
|
|
160
|
+
| 本地 OCR | ✅ 已编译 | ✅ 自编译或用 Release 二进制 | ✅ PowerShell 后端(Windows.Media.Ocr,未实测) |
|
|
161
|
+
| 云端 VLM | ✅ | ✅ | ✅(纯 Node) |
|
|
162
|
+
|
|
163
|
+
- **macOS OCR**:依赖 Vision.framework。仓库不含编译产物(见 `.gitignore`):
|
|
164
|
+
- Apple Silicon:`npm run build:ocr` 自编译,或下载 GitHub Release 的 `vision-ocr-arm64`
|
|
165
|
+
- Intel:`npm run build:ocr` 自编译,或下载 Release 的 `vision-ocr-x86_64`
|
|
166
|
+
- 打 `v*` tag 推 GitHub 时,Actions 自动在两种架构编译并附到 Release
|
|
167
|
+
- **Windows OCR**:`bin/vision-ocr.ps1`(Windows 10/11 内置 OCR 引擎,需装中文 OCR 语言包),插件 `ocrBin` 指向它即可:
|
|
168
|
+
```powershell
|
|
169
|
+
powershell -ExecutionPolicy Bypass -File bin/vision-ocr.ps1 <image> -Json
|
|
170
|
+
```
|
|
171
|
+
> 注:该脚本在 macOS 上开发,未在 Windows 实测,欢迎提交 issue/PR。
|
|
172
|
+
- **VLM 通道**:Node >= 18(内置 `fetch`),全平台可用。
|
|
173
|
+
|
|
174
|
+
## 许可证
|
|
175
|
+
|
|
176
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# vision-ocr.ps1 — Windows OCR 后端(Windows.Media.Ocr)
|
|
2
|
+
#
|
|
3
|
+
# 与 macOS 版 vision-ocr 输出相同 JSON 格式的 OCR 命令行工具。
|
|
4
|
+
# 使用 Windows 10/11 内置 OCR 引擎(Windows.Media.Ocr),免费、离线、支持中文。
|
|
5
|
+
#
|
|
6
|
+
# 用法:
|
|
7
|
+
# powershell -ExecutionPolicy Bypass -File vision-ocr.ps1 <image-path> [-Json]
|
|
8
|
+
#
|
|
9
|
+
# 注意:
|
|
10
|
+
# - 需要 Windows 10 1803+ / Windows 11
|
|
11
|
+
# - 需要安装对应语言的 OCR 语言包(设置 → 时间和语言 → 语言 → 可选功能 → 光学字符识别(OCR))
|
|
12
|
+
# - 作者在 macOS 上开发,此脚本未在 Windows 上实测,欢迎提交 issue/PR
|
|
13
|
+
# - 坐标单位为 DIP(与 macOS 版归一化坐标不同,但文本内容一致)
|
|
14
|
+
|
|
15
|
+
param(
|
|
16
|
+
[Parameter(Mandatory = $true, Position = 0)]
|
|
17
|
+
[string]$ImagePath,
|
|
18
|
+
|
|
19
|
+
[switch]$Json
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
$ErrorActionPreference = "Stop"
|
|
23
|
+
|
|
24
|
+
if (-not (Test-Path -LiteralPath $ImagePath)) {
|
|
25
|
+
Write-Error "cannot load image: $ImagePath"
|
|
26
|
+
exit 2
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# ── 加载 WinRT 类型 ───────────────────────────────────────────────────────────
|
|
30
|
+
[Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType = WindowsRuntime] | Out-Null
|
|
31
|
+
[Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime] | Out-Null
|
|
32
|
+
[Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics, ContentType = WindowsRuntime] | Out-Null
|
|
33
|
+
|
|
34
|
+
Add-Type -AssemblyName System.Runtime.WindowsRuntime -ErrorAction SilentlyContinue
|
|
35
|
+
|
|
36
|
+
# ── Await helper:把 WinRT IAsyncOperation 转成 .NET Task(PowerShell 5.1 兼容)──
|
|
37
|
+
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() |
|
|
38
|
+
Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]
|
|
39
|
+
|
|
40
|
+
function Await($WinRtTask, $ResultType) {
|
|
41
|
+
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
|
|
42
|
+
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
|
43
|
+
$netTask.Wait(-1) | Out-Null
|
|
44
|
+
return $netTask.Result
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# ── 打开并解码图片 ────────────────────────────────────────────────────────────
|
|
48
|
+
try {
|
|
49
|
+
$file = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($ImagePath)) ([Windows.Storage.StorageFile])
|
|
50
|
+
} catch {
|
|
51
|
+
Write-Error "cannot open image: $ImagePath ($_)"
|
|
52
|
+
exit 3
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
|
|
56
|
+
$decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
|
|
57
|
+
$bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
|
|
58
|
+
|
|
59
|
+
# ── OCR 引擎(优先中文) ──────────────────────────────────────────────────────
|
|
60
|
+
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
|
|
61
|
+
if ($null -eq $engine) {
|
|
62
|
+
Write-Error "no OCR language pack available; install OCR language packs in Settings"
|
|
63
|
+
exit 4
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$result = Await ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
|
|
67
|
+
|
|
68
|
+
# ── 组装输出(与 macOS vision-ocr 的 --json 格式一致) ───────────────────────
|
|
69
|
+
$lines = @()
|
|
70
|
+
foreach ($line in $result.Lines) {
|
|
71
|
+
$words = @($line.Words)
|
|
72
|
+
if ($words.Count -eq 0) { continue }
|
|
73
|
+
$first = $words[0].BoundingRect
|
|
74
|
+
$last = $words[$words.Count - 1].BoundingRect
|
|
75
|
+
$lines += [PSCustomObject]@{
|
|
76
|
+
text = $line.Text
|
|
77
|
+
confidence = 1.0
|
|
78
|
+
x = [math]::Round($first.X, 3)
|
|
79
|
+
y = [math]::Round($first.Y, 3)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
$resultObj = [PSCustomObject]@{
|
|
84
|
+
width = $decoder.PixelWidth
|
|
85
|
+
height = $decoder.PixelHeight
|
|
86
|
+
lines = $lines
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ($Json) {
|
|
90
|
+
$resultObj | ConvertTo-Json -Depth 5
|
|
91
|
+
} else {
|
|
92
|
+
Write-Host "image: $ImagePath size: $($decoder.PixelWidth)x$($decoder.PixelHeight)"
|
|
93
|
+
Write-Host "--- OCR ---"
|
|
94
|
+
if ($lines.Count -eq 0) { Write-Host "(no text detected)" }
|
|
95
|
+
foreach ($l in $lines) { Write-Host $l.text }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$stream.Dispose()
|
|
99
|
+
exit 0
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// vision-ocr.swift — macOS Vision.framework OCR 命令行工具
|
|
2
|
+
// 用法: vision-ocr <image-path> [--lang zh-Hans] [--json]
|
|
3
|
+
// 输出: 图片元信息 + 按阅读顺序排序的识别文本行
|
|
4
|
+
import Foundation
|
|
5
|
+
import Vision
|
|
6
|
+
import AppKit
|
|
7
|
+
|
|
8
|
+
func fail(_ msg: String, code: Int32) -> Never {
|
|
9
|
+
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
|
|
10
|
+
exit(code)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let args = CommandLine.arguments
|
|
14
|
+
guard args.count >= 2 else {
|
|
15
|
+
fail("usage: vision-ocr <image> [--lang zh-Hans] [--json]", code: 1)
|
|
16
|
+
}
|
|
17
|
+
let imagePath = args[1]
|
|
18
|
+
var language = "zh-Hans"
|
|
19
|
+
var wantsJSON = false
|
|
20
|
+
if let idx = args.firstIndex(of: "--lang"), idx + 1 < args.count { language = args[idx + 1] }
|
|
21
|
+
if args.contains("--json") { wantsJSON = true }
|
|
22
|
+
|
|
23
|
+
guard let image = NSImage(contentsOfFile: imagePath) else {
|
|
24
|
+
fail("cannot load image: \(imagePath)", code: 2)
|
|
25
|
+
}
|
|
26
|
+
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
27
|
+
fail("cannot convert image to CGImage: \(imagePath)", code: 3)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let width = cgImage.width
|
|
31
|
+
let height = cgImage.height
|
|
32
|
+
|
|
33
|
+
let request = VNRecognizeTextRequest()
|
|
34
|
+
request.recognitionLevel = .accurate
|
|
35
|
+
request.recognitionLanguages = [language, "en-US"]
|
|
36
|
+
request.usesLanguageCorrection = true
|
|
37
|
+
|
|
38
|
+
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
|
39
|
+
do {
|
|
40
|
+
try handler.perform([request])
|
|
41
|
+
} catch {
|
|
42
|
+
fail("Vision request failed: \(error)", code: 4)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
struct Line {
|
|
46
|
+
let text: String
|
|
47
|
+
let y: Double // Vision 坐标 (0-1, 底部为 0)
|
|
48
|
+
let x: Double
|
|
49
|
+
let confidence: Float
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
var lines: [Line] = []
|
|
53
|
+
for obs in request.results ?? [] {
|
|
54
|
+
guard let cand = obs.topCandidates(1).first else { continue }
|
|
55
|
+
let box = obs.boundingBox
|
|
56
|
+
lines.append(Line(text: cand.string, y: Double(box.midY), x: Double(box.minX), confidence: cand.confidence))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 按阅读顺序:从顶到底,同行内从左到右
|
|
60
|
+
lines.sort { a, b in
|
|
61
|
+
if abs(a.y - b.y) > 0.02 { return a.y > b.y }
|
|
62
|
+
return a.x < b.x
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if wantsJSON {
|
|
66
|
+
let rows = lines.map { l in
|
|
67
|
+
"{\"text\": \(jsonEscape(l.text)), \"confidence\": \(String(format: "%.3f", l.confidence)), \"x\": \(String(format: "%.3f", l.x)), \"y\": \(String(format: "%.3f", l.y))}"
|
|
68
|
+
}
|
|
69
|
+
let payload = """
|
|
70
|
+
{"width": \(width), "height": \(height), "lines": [\(rows.joined(separator: ","))]}
|
|
71
|
+
"""
|
|
72
|
+
print(payload)
|
|
73
|
+
} else {
|
|
74
|
+
print("image: \(imagePath) size: \(width)x\(height)")
|
|
75
|
+
print("--- OCR (\(language)) ---")
|
|
76
|
+
if lines.isEmpty {
|
|
77
|
+
print("(no text detected)")
|
|
78
|
+
}
|
|
79
|
+
for l in lines {
|
|
80
|
+
print(l.text)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func jsonEscape(_ s: String) -> String {
|
|
85
|
+
var out = "\""
|
|
86
|
+
for ch in s.unicodeScalars {
|
|
87
|
+
switch ch {
|
|
88
|
+
case "\"": out += "\\\""
|
|
89
|
+
case "\\": out += "\\\\"
|
|
90
|
+
case "\n": out += "\\n"
|
|
91
|
+
case "\r": out += "\\r"
|
|
92
|
+
case "\t": out += "\\t"
|
|
93
|
+
default:
|
|
94
|
+
if ch.value < 0x20 {
|
|
95
|
+
out += String(format: "\\u%04x", ch.value)
|
|
96
|
+
} else {
|
|
97
|
+
out.append(Character(ch))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
out += "\""
|
|
102
|
+
return out
|
|
103
|
+
}
|
package/lib/core.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// dsh-vision 核心纯逻辑(无 Cordis 依赖,可独立单元测试)
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
/** VLM 请求超时(毫秒) */
|
|
7
|
+
export const VLM_TIMEOUT_MS = 120_000;
|
|
8
|
+
/** VLM 输出 token 上限 */
|
|
9
|
+
export const VLM_MAX_TOKENS = 1024;
|
|
10
|
+
/** 上传前图片压缩的最长边(像素) */
|
|
11
|
+
export const VLM_MAX_IMAGE_DIM = 2048;
|
|
12
|
+
/** 压缩 JPEG 质量(0-100) */
|
|
13
|
+
export const JPEG_QUALITY = 85;
|
|
14
|
+
/** 本地 OCR 单次执行超时(毫秒) */
|
|
15
|
+
export const OCR_TIMEOUT_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
/** 默认的画面理解提问(当用户未提供具体问题时使用) */
|
|
18
|
+
export const DEFAULT_DESCRIBE_PROMPT =
|
|
19
|
+
"请详细描述这张图片的内容:画面主体、场景、人物、构图、颜色、光线、以及任何值得注意的细节。";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 从环境变量或 ~/.dsh/.credentials.yaml 解析 API Key。
|
|
23
|
+
* 优先读环境变量;其次读 DSH 凭据文件中的同名条目。
|
|
24
|
+
* @param {string} envName - 环境变量名(如 DASHSCOPE_API_KEY)
|
|
25
|
+
* @returns {string|null} 解析到的 Key,未配置返回 null
|
|
26
|
+
*/
|
|
27
|
+
export function resolveApiKey(envName) {
|
|
28
|
+
if (process.env[envName]) return process.env[envName];
|
|
29
|
+
try {
|
|
30
|
+
const credPath = join(homedir(), ".dsh", ".credentials.yaml");
|
|
31
|
+
if (!existsSync(credPath)) return null;
|
|
32
|
+
const content = readFileSync(credPath, "utf8");
|
|
33
|
+
const re = new RegExp(`^${envName}\\s*[:=]\\s*(.+)$`, "m");
|
|
34
|
+
const m = content.match(re);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
return m[1].trim().replace(/^["']|["']$/g, "");
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 从 OpenAI 兼容 chat/completions 响应中提取助手文本。
|
|
44
|
+
* @param {unknown} data - API 返回的 JSON
|
|
45
|
+
* @returns {string} 提取到的文本;缺失时返回空字符串
|
|
46
|
+
*/
|
|
47
|
+
export function extractDescription(data) {
|
|
48
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
49
|
+
if (typeof content === "string") return content;
|
|
50
|
+
if (Array.isArray(content)) {
|
|
51
|
+
// 部分模型返回 content 数组(Anthropic 风格块)
|
|
52
|
+
return content
|
|
53
|
+
.map((b) => (b?.type === "text" ? b.text : ""))
|
|
54
|
+
.join("")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 构建 OpenAI 兼容的 VLM 请求体。
|
|
62
|
+
* @param {object} opts - { model, prompt, imageBase64, mimeType }
|
|
63
|
+
* @returns {object} chat/completions 请求体
|
|
64
|
+
*/
|
|
65
|
+
export function buildVLMRequestBody({ model, prompt, imageBase64, mimeType }) {
|
|
66
|
+
return {
|
|
67
|
+
model,
|
|
68
|
+
messages: [{
|
|
69
|
+
role: "user",
|
|
70
|
+
content: [
|
|
71
|
+
{ type: "text", text: prompt },
|
|
72
|
+
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${imageBase64}` } },
|
|
73
|
+
],
|
|
74
|
+
}],
|
|
75
|
+
max_tokens: VLM_MAX_TOKENS,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 根据文件扩展名推断图片 MIME 类型(未知按 jpeg 兜底) */
|
|
80
|
+
export function mimeTypeForPath(filePath) {
|
|
81
|
+
return filePath.toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 从多供应商配置中解析指定供应商。
|
|
86
|
+
* @param {object} config - 插件 Config(含 providers 与 defaultProvider)
|
|
87
|
+
* @param {string} [providerId] - 指定供应商 id;缺省用 defaultProvider
|
|
88
|
+
* @returns {{id: string, baseUrl: string, model: string, apiKeyEnv: string}} 供应商配置
|
|
89
|
+
* @throws {Error} 供应商不存在时
|
|
90
|
+
*/
|
|
91
|
+
export function resolveProvider(config, providerId) {
|
|
92
|
+
const id = providerId || config.defaultProvider || "bailian";
|
|
93
|
+
const provider = config.providers?.[id];
|
|
94
|
+
if (!provider) {
|
|
95
|
+
const available = Object.keys(config.providers ?? {}).join(", ");
|
|
96
|
+
throw new Error(
|
|
97
|
+
`未知的 VLM 供应商 "${id}"。可用:${available || "(未配置任何供应商)"}。` +
|
|
98
|
+
"可在 cordis.patch.yml 中 dsh-vision 节点的 config.providers 下添加。"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return { id, baseUrl: provider.baseUrl, model: provider.model, apiKeyEnv: provider.apiKeyEnv };
|
|
102
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// dsh-vision 插件:给模型提供图片理解能力
|
|
2
|
+
// - read_image_text : 本地 OCR(macOS Vision),免费离线,识别图中文字
|
|
3
|
+
// - describe_image : 云端 VLM(OpenAI 兼容端点,默认阿里云百炼 qwen3-vl-flash),理解画面内容
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { readFile, unlink } from "node:fs/promises";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import z from "@deepseek-ai/schemastery";
|
|
10
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
11
|
+
import {
|
|
12
|
+
VLM_TIMEOUT_MS,
|
|
13
|
+
VLM_MAX_IMAGE_DIM,
|
|
14
|
+
JPEG_QUALITY,
|
|
15
|
+
OCR_TIMEOUT_MS,
|
|
16
|
+
DEFAULT_DESCRIBE_PROMPT,
|
|
17
|
+
resolveApiKey,
|
|
18
|
+
extractDescription,
|
|
19
|
+
buildVLMRequestBody,
|
|
20
|
+
mimeTypeForPath,
|
|
21
|
+
resolveProvider,
|
|
22
|
+
} from "./core.js";
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
export const name = "dsh-vision";
|
|
28
|
+
export const inject = ["tools"];
|
|
29
|
+
|
|
30
|
+
const providerSchema = z.object({
|
|
31
|
+
baseUrl: z.string(),
|
|
32
|
+
model: z.string(),
|
|
33
|
+
apiKeyEnv: z.string(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const Config = z.object({
|
|
37
|
+
/** 本地 OCR 可执行文件路径(默认指向插件 bin/vision-ocr) */
|
|
38
|
+
ocrBin: z.string().default(""),
|
|
39
|
+
/** 默认 VLM 供应商 id(对应 providers 的键名) */
|
|
40
|
+
defaultProvider: z.string().default("bailian"),
|
|
41
|
+
/** 多供应商 VLM 配置:每个供应商为 OpenAI 兼容端点 */
|
|
42
|
+
providers: z.object({
|
|
43
|
+
bailian: providerSchema.default({
|
|
44
|
+
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
45
|
+
model: "qwen3-vl-flash",
|
|
46
|
+
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
47
|
+
}),
|
|
48
|
+
siliconflow: providerSchema.default({
|
|
49
|
+
baseUrl: "https://api.siliconflow.cn/v1",
|
|
50
|
+
model: "Qwen/Qwen2.5-VL-7B-Instruct",
|
|
51
|
+
apiKeyEnv: "SILICONFLOW_API_KEY",
|
|
52
|
+
}),
|
|
53
|
+
zhipu: providerSchema.default({
|
|
54
|
+
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
|
55
|
+
model: "glm-4v-flash",
|
|
56
|
+
apiKeyEnv: "ZHIPU_API_KEY",
|
|
57
|
+
}),
|
|
58
|
+
volcengine: providerSchema.default({
|
|
59
|
+
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
|
60
|
+
model: "doubao-seed-1.6-vision",
|
|
61
|
+
apiKeyEnv: "ARK_API_KEY",
|
|
62
|
+
}),
|
|
63
|
+
}),
|
|
64
|
+
/** 上传前压缩图片的最长边(像素) */
|
|
65
|
+
vlmMaxImageDim: z.number().default(VLM_MAX_IMAGE_DIM),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** 用 sips 把图片压到最长边 <= maxDim 的 JPEG;失败时退回原图(不落盘) */
|
|
69
|
+
async function prepareImage(filePath, maxDim) {
|
|
70
|
+
const tmpOut = join(__dirname, "..", ".cache", `vlm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.jpg`);
|
|
71
|
+
try {
|
|
72
|
+
await execFileAsync("sips", [
|
|
73
|
+
"-Z", String(maxDim),
|
|
74
|
+
"-s", "format", "jpeg",
|
|
75
|
+
"-s", "formatOptions", String(JPEG_QUALITY),
|
|
76
|
+
filePath, "--out", tmpOut,
|
|
77
|
+
]);
|
|
78
|
+
return { path: tmpOut, cleanup: () => void unlink(tmpOut).catch(() => {}) };
|
|
79
|
+
} catch {
|
|
80
|
+
return { path: filePath, cleanup: () => {} };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 调用 OpenAI 兼容 VLM 端点,返回描述文本 */
|
|
85
|
+
async function callVLM({ baseUrl, model, apiKey, imagePath, question }) {
|
|
86
|
+
const { path, cleanup } = await prepareImage(imagePath, VLM_MAX_IMAGE_DIM);
|
|
87
|
+
try {
|
|
88
|
+
const imageBytes = await readFile(path);
|
|
89
|
+
const imageBase64 = imageBytes.toString("base64");
|
|
90
|
+
const prompt = question && question.trim().length > 0 ? question : DEFAULT_DESCRIBE_PROMPT;
|
|
91
|
+
const body = buildVLMRequestBody({
|
|
92
|
+
model,
|
|
93
|
+
prompt,
|
|
94
|
+
imageBase64,
|
|
95
|
+
mimeType: mimeTypeForPath(path),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), VLM_TIMEOUT_MS);
|
|
100
|
+
let res;
|
|
101
|
+
try {
|
|
102
|
+
res = await fetch(`${baseUrl}/chat/completions`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
});
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
const detail = (await res.text()).slice(0, 500);
|
|
114
|
+
throw new Error(`VLM HTTP ${res.status}: ${detail}`);
|
|
115
|
+
}
|
|
116
|
+
const description = extractDescription(await res.json());
|
|
117
|
+
return description || "(空回复)";
|
|
118
|
+
} finally {
|
|
119
|
+
cleanup();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function apply(ctx, config) {
|
|
124
|
+
const resolved = config;
|
|
125
|
+
const ocrBin = resolved.ocrBin && resolved.ocrBin.length > 0
|
|
126
|
+
? resolved.ocrBin
|
|
127
|
+
: join(__dirname, "..", "bin", "vision-ocr");
|
|
128
|
+
|
|
129
|
+
// ── 工具1:本地 OCR ────────────────────────────────────────────────
|
|
130
|
+
ctx.tools.register(defineTool({
|
|
131
|
+
name: "read_image_text",
|
|
132
|
+
description: "识别图片中的文字(OCR)。使用 macOS 本地 Vision 引擎,免费、离线、隐私安全,支持中英文。返回按阅读顺序排列的文本行与图片尺寸。",
|
|
133
|
+
parameters: {
|
|
134
|
+
file_path: {
|
|
135
|
+
type: "string",
|
|
136
|
+
required: true,
|
|
137
|
+
description: "图片文件路径(PNG/JPEG 等)",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
output: {
|
|
141
|
+
schema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
additionalProperties: false,
|
|
144
|
+
properties: {
|
|
145
|
+
path: { type: "string", required: true },
|
|
146
|
+
width: { type: "integer", required: true },
|
|
147
|
+
height: { type: "integer", required: true },
|
|
148
|
+
text: { type: "string", required: true },
|
|
149
|
+
lines: { type: "array", items: { type: "string" } },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
render: (_args, value) => [{
|
|
153
|
+
type: "text",
|
|
154
|
+
text: `【图片文字识别】 ${value.path} (${value.width}x${value.height})\n${value.text}`,
|
|
155
|
+
}],
|
|
156
|
+
},
|
|
157
|
+
async execute(args) {
|
|
158
|
+
const { stdout } = await execFileAsync(ocrBin, [args.file_path, "--json"], { timeout: OCR_TIMEOUT_MS });
|
|
159
|
+
const parsed = JSON.parse(stdout);
|
|
160
|
+
const lines = parsed.lines.map((l) => l.text);
|
|
161
|
+
return {
|
|
162
|
+
path: args.file_path,
|
|
163
|
+
width: parsed.width,
|
|
164
|
+
height: parsed.height,
|
|
165
|
+
text: lines.length > 0 ? lines.join("\n") : "(未检测到文字)",
|
|
166
|
+
lines,
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
// ── 工具2:云端 VLM 画面理解 ──────────────────────────────────────
|
|
172
|
+
ctx.tools.register(defineTool({
|
|
173
|
+
name: "describe_image",
|
|
174
|
+
description: "理解图片的画面内容(视觉语言模型 VLM)。调用可配置的云端视觉模型(默认阿里云百炼 qwen3-vl-flash),返回对图片内容的自然语言描述或对指定问题的回答。需要先配置 API Key(环境变量或 ~/.dsh/.credentials.yaml)。",
|
|
175
|
+
parameters: {
|
|
176
|
+
file_path: {
|
|
177
|
+
type: "string",
|
|
178
|
+
required: true,
|
|
179
|
+
description: "图片文件路径(PNG/JPEG)",
|
|
180
|
+
},
|
|
181
|
+
question: {
|
|
182
|
+
type: "string",
|
|
183
|
+
required: false,
|
|
184
|
+
description: "可选的具体问题;留空则描述整体画面内容",
|
|
185
|
+
},
|
|
186
|
+
provider: {
|
|
187
|
+
type: "string",
|
|
188
|
+
required: false,
|
|
189
|
+
description: "可选:VLM 供应商 id(bailian / siliconflow / zhipu / volcengine,或自定义)。留空用默认供应商。",
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
output: {
|
|
193
|
+
schema: {
|
|
194
|
+
type: "object",
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
properties: {
|
|
197
|
+
path: { type: "string", required: true },
|
|
198
|
+
model: { type: "string", required: true },
|
|
199
|
+
description: { type: "string", required: true },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
render: (_args, value) => [{
|
|
203
|
+
type: "text",
|
|
204
|
+
text: `【图片理解·${value.model}】 ${value.path}\n${value.description}`,
|
|
205
|
+
}],
|
|
206
|
+
},
|
|
207
|
+
async execute(args) {
|
|
208
|
+
const provider = resolveProvider(resolved, args.provider);
|
|
209
|
+
const apiKey = resolveApiKey(provider.apiKeyEnv);
|
|
210
|
+
if (!apiKey) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`describe_image (${provider.id}) 需要 API Key:请设置环境变量 ${provider.apiKeyEnv},` +
|
|
213
|
+
`或添加到 ~/.dsh/.credentials.yaml(例如:${provider.apiKeyEnv}: sk-xxx)。`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const description = await callVLM({
|
|
217
|
+
baseUrl: provider.baseUrl,
|
|
218
|
+
model: provider.model,
|
|
219
|
+
apiKey,
|
|
220
|
+
imagePath: args.file_path,
|
|
221
|
+
question: args.question,
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
path: args.file_path,
|
|
225
|
+
model: `${provider.id}:${provider.model}`,
|
|
226
|
+
description,
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
}));
|
|
230
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@floatingsk/dsh-vision",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh 视觉插件:本地 OCR(macOS Vision)+ 云端 VLM 图片理解(OpenAI 兼容)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"lib",
|
|
12
|
+
"bin/vision-ocr.swift",
|
|
13
|
+
"bin/vision-ocr.ps1",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test \"test/*.test.js\"",
|
|
19
|
+
"build:ocr": "swiftc -Xcc -fmodules-cache-path=\"$PWD/.cache\" -O bin/vision-ocr.swift -o bin/vision-ocr"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"dsh",
|
|
26
|
+
"deepseek-harness",
|
|
27
|
+
"cordis",
|
|
28
|
+
"plugin",
|
|
29
|
+
"vision",
|
|
30
|
+
"vlm",
|
|
31
|
+
"ocr",
|
|
32
|
+
"image"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
37
|
+
"@deepseek-ai/schemastery": "*"
|
|
38
|
+
}
|
|
39
|
+
}
|