初始化 kimi-eyes:给 KimiCode 非多模态模型补上视觉能力

This commit is contained in:
billowliu2 committed 2026-08-02 01:24:28 +08:00
commit ed8f7ae977
12 files changed
+1510

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# dependencies
node_modules/
# npm publish artifacts
*.tgz
# local secrets — never commit (credentials live in user-level ~/.npmrc)
.env
.env.*
# local verification / scratch
.verify/
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 billowliu2
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+268
View File
@@ -0,0 +1,268 @@
# Kimi Eyes 👀
> [中文](README.md) | English
Give **non-multimodal models** in Kimi Code the ability to analyze images and
screenshots (inspired by [opencode-vision](https://github.com/JochenYang/opencode-vision),
but thinner: no hooks, no message transforms, no leftover state).
**How it works**: the plugin declares an MCP stdio server exposing two tools —
`read_image` (read a local image) and `read_clipboard_image` (read a clipboard
screenshot) — and a `SYSTEM.md` guide tells the model when to call them. The server
sends the image to your own vision API (OpenAI-compatible or Anthropic protocol) and
returns the text description.
```
Multimodal models: paste with Alt+V, see natively — the plugin stays idle.
Non-multimodal models: @image-path / "analyze this screenshot"
→ model calls mcp__kimi-eyes__read_image / read_clipboard_image
→ your VLM returns a description
```
## Prerequisites
- Kimi Code CLI (with `/plugins` and MCP support)
- Node.js ≥ 18 (`node --version`; native `fetch` requires 18+)
- A multimodal vision API of your own (OpenAI-compatible `chat/completions`, or
Anthropic `messages`) — you provide the key
## Compatibility
**The plugin puts no restriction on your main model.** No matter which model your
Kimi Code session is running, or which provider it comes from, as long as it lacks
native image input (`image_in`), this plugin adds vision to it:
- **Common non-multimodal models**: DeepSeek (`deepseek-chat`), Qwen text-only
(`qwen-plus` / `qwen-turbo`), Llama text variants, and locally deployed text
models (Ollama / vLLM, etc.)
- **Any third-party model**: any OpenAI-compatible or custom model configured in
Kimi Code — if it cannot see images natively, the plugin guides it to call the
vision tools whenever an image is involved
- **Multimodal models**: skipped automatically (paste with Alt+V, see natively)
Vision capability is provided by the **VLM you configure**, fully decoupled from
the main model:
| Protocol | Example vision models |
| --- | --- |
| OpenAI-compatible | qwen-vl series, GLM-4V, GPT-4o / GPT-5 compatible endpoints, Gemini-compatible endpoints, etc. |
| Anthropic | Claude 3.5 / 3.7 / 4 series, etc. |
In short: **the main model handles text, the external VLM handles images.** When
configuring, just pick the protocol that matches your vision API provider (step 1
of the setup wizard); everything else is automatic.
## Quick start
### 1. Install the plugin
In a Kimi Code session:
```
/plugins install D:\AIGC\Plugin\kimi-eyes
```
### 2. Configure the vision API (one-time)
Run the setup wizard from the plugin directory:
```
cd D:\AIGC\Plugin\kimi-eyes
node setup.mjs
```
The wizard walks you through: **choose protocol (OpenAI-compatible / Anthropic) →
enter Base URL → enter API Key (masked input) → fetch model list and pick a
multimodal model → 1×1 image vision check → optionally enter your main model name
(for trigger decisions) → write config**.
- The model list is fetched from `GET {BaseUrl}/models`; entries are tagged
**"✓vision / text"** using the bundled models.dev database, with a "★guess" fallback
for models the database does not know; if fetching fails it falls back to manual entry
- The vision check must pass before the config is saved, so you never end up with a
model that rejects images
- The last step asks for your **main model name** (optional): the plugin looks it up in
models.dev, and if that model supports native image input the tools refuse with a
hint — the code-level "multimodal → don't trigger" switch (see
[Triggering](#triggering-everyday-fully-automatic))
- Config is written to `~/.kimi-code/kimi-eyes/config.json` (`chmod 600` on
non-Windows systems)
### 3. Enable
```
/reload
```
The MCP server starts automatically with the session.
### 4. Use it
| Scenario | Action |
| --- | --- |
| Multimodal model | Paste with Alt+V directly — native vision, plugin not involved |
| Non-multimodal + image path | Type `@screenshot.png` or paste the path; the model calls `read_image` |
| Non-multimodal + just screenshotted/copied | Ask "analyze this screenshot"; the model calls `read_clipboard_image` to read the system clipboard |
## Activation and triggering
### Activation (one-time)
```
/plugins install D:\AIGC\Plugin\kimi-eyes # 1. Install
/reload # 2. Enable (or start a new session with /new)
```
Once enabled, the MCP server starts automatically with every session — there is no
separate "turn on the feature" step. To verify:
- `/plugins list` → kimi-eyes should show as enabled
- `/mcp` → the kimi-eyes server should show as connected
- `/plugins info kimi-eyes` → should show no diagnostics errors
### Triggering (everyday, fully automatic)
The plugin is **passive**: `SYSTEM.md` plants the rules into the model, and the model
calls the tools automatically at the right moment — you do nothing:
| Signal (anything in the message) | Model's automatic behavior |
| --- | --- |
| Image-format path or `@` reference (`.png/.jpg/.jpeg/.webp/.gif/.bmp`) | **Hard trigger**: unconditionally calls `read_image(path)`, regardless of wording |
| Media content you cannot interpret (e.g. a pasted image) | Ignores that media part, calls `read_clipboard_image()` (the pasted image is almost always still in the clipboard) |
| Wording implies image content: image / screenshot / photo / UI / chart / CAPTCHA / OCR, etc., but no path | Calls `read_clipboard_image()` |
| Model natively supports `image_in` (multimodal) | Skips all rules, sees images natively, never calls the tools |
**Triggering does not depend on fixed wording** — the user does not need to say
"analyze the image". Image-format paths are an **unconditional hard trigger**; when
in doubt, the model is instructed to call a tool rather than guess.
**On model-capability detection**: Kimi Code does not expose "is the current model
multimodal?" to plugins, so this plugin provides two layers:
- **Code-level (recommended)**: declare `mainModel` in the config (last wizard step,
or the `VISION_MAIN_MODEL` environment variable). The plugin looks it up in the
bundled models.dev database — if it supports image input, the tools refuse with a
hint ("paste the image directly"); if it is text-only, the tools proceed normally.
- **Prompt-level**: without `mainModel`, the SYSTEM.md explicit skip rule handles it
(the model recognizes its own capability).
Both layers are fail-safe: a wrong judgment costs at most one wasted external call
(a multimodal model calling a tool) or a missed trigger (a text model — covered by
the path/semantic signals). For a hard switch, simply `/plugins disable kimi-eyes`
when running multimodal models.
**The first tool call prompts one approval** (MCP tool permission): choose *Approve
for this session* to skip prompts for the rest of the session; for permanent
approval, add to `~/.kimi-code/config.toml`:
```toml
[[permission.rules]]
decision = "allow"
pattern = "mcp__kimi-eyes__*"
```
A successful trigger looks like: a tool call appears in the TUI before the answer,
and the model then answers based on the returned description. If the model does not
call the tool on its own (e.g. you pasted a path without asking a question), just
command it: "Call the read_image tool to analyze D:\xxx.png".
## Environment variables (optional; override the config file)
| Variable | Description | Priority |
| --- | --- | --- |
| `VISION_API_PROTOCOL` | `openai` or `anthropic`; force the protocol | Higher than config file |
| `VISION_API_KEY` | API key | Higher than config file |
| `VISION_API_URL` | Base URL | Higher than config file |
| `VISION_MODEL` | Model name | Higher than config file |
| `VISION_MAIN_MODEL` | Your main model name (optional), used for trigger decisions | Higher than config file |
| `VISION_MAX_TOKENS` | Max tokens for the vision response (default 1024) | — |
| `VISION_FETCH_TIMEOUT_MS` | Request timeout in ms (default 60000) | — |
> When an environment variable conflicts with `config.json`, the variable wins. If
> neither is set, the tools return a clear error pointing at `setup.mjs`.
> Variable names are compatible with opencode-vision, so migrating is trivial.
## Protocol details
| | OpenAI-compatible | Anthropic |
| --- | --- | --- |
| Request endpoint | `{BaseUrl}/chat/completions` (`/v1` auto-appended) | `{BaseUrl}/v1/messages` |
| Auth | `Authorization: Bearer <Key>` | `x-api-key: <Key>` + `anthropic-version: 2023-06-01` |
| Image payload | `image_url` + base64 data URL | `source: {type:"base64"}` |
| Model list | `GET {BaseUrl}/models` | `GET {BaseUrl}/v1/models` (not an official Anthropic endpoint; falls back to manual entry) |
## Model capability database (models.dev)
The plugin ships a slim capability cache `mcp/models-db.json` synced from
[models.dev](https://models.dev) (currently 279+ models, tagged with whether each
supports image input, ~19 KB). It powers two things:
- **Exact tagging** in the setup wizard's model picker ("✓vision / text"), replacing
pure keyword guessing
- The **code-level trigger switch**: once `mainModel` is declared, the plugin checks
whether your main model is multimodal
**Sync at packaging time** (the data evolves — run before each release):
```
node scripts/sync-models.mjs
```
- Source: `https://models.dev/models.json`
- Behind a proxy: set `HTTPS_PROXY`, e.g. `HTTPS_PROXY=http://127.0.0.1:7897 node scripts/sync-models.mjs`
- Options: `--timeout <seconds>` (default 180), `--out <path>` (default
`mcp/models-db.json`), `--endpoint <URL>`
- Matching strategy: exact id → `provider/model-name` suffix → case-insensitive name
- **Unknown models**: lookup returns unknown — the wizard falls back to keyword
guessing ("★guess") and triggering falls back to the SYSTEM.md rules; nothing breaks
- **Manual extras**: models.dev does not list every vision model (e.g. `k3-256k`,
`kimi-for-coding`). Keep them in `EXTRA_ENTRIES` inside
`scripts/sync-models.mjs` — every sync merges them in, so re-syncs never drop them
## Tools
| Tool | Arguments | Description |
| --- | --- | --- |
| `read_image` | `path` (required), `prompt` (optional) | Validates the file is an image (extension + magic bytes), then calls the VLM |
| `read_clipboard_image` | `prompt` (optional) | Captures the clipboard image to a temp file, calls the VLM, deletes the temp file |
Clipboard capture depends on the platform: Windows uses PowerShell (built-in),
macOS needs `pngpaste` (`brew install pngpaste`), Linux needs `wl-paste` (Wayland)
or `xclip` (X11).
## Troubleshooting
- **Tool returns "Vision API is not configured"** → run `node setup.mjs`, or set
`VISION_API_KEY` / `VISION_API_URL` / `VISION_MODEL`
- **Model list fetch fails** (404/401) → the wizard falls back to manual entry; if
your provider has no `/models` endpoint, just type the model name
- **Vision check fails** → pick a model that really accepts image input (e.g.
`qwen-vl-max`, `glm-4v`, `gpt-4o`, `claude-3-5-sonnet` — check your provider's docs)
- **`read_clipboard_image` errors** → make sure the clipboard actually holds an image
(Ctrl+C an image or Win+Shift+S a screenshot first); on macOS/Linux check the
platform tool above is installed
- **Tools not showing up after install** → verify the plugin is enabled
(`/plugins list`) and run `/reload` or start a new session
## Security notes
- `config.json` stores your API key in plain text (`600` perms on non-Windows);
never commit it to a repository
- `read_clipboard_image` reads the system clipboard — it may contain sensitive
content you just copied. The tool call goes through the approval flow, so you
decide when it runs
- This project ships no credentials; vision requests go only to the Base URL you
configured
## Limitations
- No subagent delegation: Kimi Code's `model_preference` only supports
primary/secondary (it cannot name a specific vision model the way opencode can),
so the value is limited
- No `UserPromptSubmit` hook fallback: the `SYSTEM.md` guide covers the common
cases; if paste/reference behavior misbehaves in your TUI, a hook can be
re-evaluated then
## License
MIT
+198
View File
@@ -0,0 +1,198 @@
# Kimi Eyes 👀
> [English](README.en.md) | 中文
让 KimiCode 的**非多模态模型**也能分析图片与截图(借鉴 [opencode-vision](https://github.com/JochenYang/opencode-vision) 的思路,但更薄:无 hook、无消息变换、无状态残留)。
**核心机制**:插件声明一个 MCP stdio 服务器,暴露两个工具——`read_image`(读本地图片)与 `read_clipboard_image`(读剪贴板截图),经 `SYSTEM.md` 引导模型调用;服务器按你配置的协议(OpenAI 兼容 / Anthropic)把图片发给你自己配置的多模态 API,返回文本描述。
```
多模态模型:Alt+V 粘贴即看,插件自动闲置
非多模态模型:@图片路径 / 截图后提问 → 模型调 mcp__kimi-eyes__read_image / read_clipboard_image → 你的 VLM 返回描述
```
## 前置要求
- KimiCode CLI(本插件通过插件机制加载,需支持 `/plugins` 与 MCP)
- Node.js ≥ 18(`node --version` 检查;原生 fetch 需要 18+)
- 一个支持视觉输入的多模态 API(OpenAI 兼容 `chat/completions`,或 Anthropic `messages`),Key 由你自己提供
## 适用范围
**本插件对主模型没有任何限制**——无论你的 KimiCode 当前用哪个模型、来自哪家服务商,只要它不具备原生图片输入能力(`image_in`),本插件就能为它补上视觉:
- **常见非多模态模型**:DeepSeek(deepseek-chat)、Qwen 纯文本版(qwen-plus / qwen-turbo)、Llama 文本系列、以及本地部署的文本模型(Ollama / vLLM 等)
- **任何第三方模型**:通过 KimiCode 配置的任意 OpenAI 兼容或自定义模型,只要它原生不看图,插件就会在消息含图片时引导模型调用视觉工具
- **多模态模型**:自动跳过(Alt+V 粘贴即看,插件闲置)
视觉能力由**你自己配置的 VLM** 提供,与主模型完全解耦:
| 协议 | 常见视觉模型示例 |
| --- | --- |
| OpenAI 兼容 | qwen-vl 系列、GLM-4V、GPT-4o / GPT-5 兼容端点、Gemini 兼容端点等 |
| Anthropic | Claude 3.5 / 3.7 / 4 系列等 |
一句话:**主模型只管理解文字,看图交给外部 VLM**。配置时只需在 setup 向导第一步选择与你视觉 API 服务商匹配的协议,其余全部自动。
## 快速开始
### 1. 安装插件
在 KimiCode 会话里执行:
```
/plugins install D:\AIGC\Plugin\kimi-eyes
```
### 2. 配置视觉 API(一次性)
在插件目录运行配置向导:
```
cd D:\AIGC\Plugin\kimi-eyes
node setup.mjs
```
向导按顺序引导:**选择协议(OpenAI 兼容 / Anthropic)→ 填写 BaseUrl → 填写 API Key(掩码输入)→ 拉取模型列表选择多模态模型 → 1×1 图片视觉验证 → 可选填写当前主模型名(用于触发判断)→ 写入配置**。
- 模型列表自动从 `GET {BaseUrl}/models` 拉取,用内置的 models.dev 数据库**精确标注「✓视觉 / 文本」**,未收录的按关键词标「★疑似」;拉取失败时回退为手动输入
- 视觉验证通过才落盘,防止配到一个不收图片的模型
- 最后一步可填写**当前主模型名**(可选):插件用 models.dev 判定后,若该模型支持原生识图,工具会直接提示无需调用——即「多模态不触发」的代码级判断(详见[触发](#触发日常全自动))
- 配置写入 `~/.kimi-code/kimi-eyes/config.json`(类 Unix 系统自动 `chmod 600`)
### 3. 启用
```
/reload
```
MCP 服务器会随会话自动启动。
### 4. 使用
| 场景 | 操作 |
| --- | --- |
| 多模态模型 | 直接 Alt+V 粘贴图片,原生看图,插件不参与 |
| 非多模态 + 有图片路径 | 输入 `@截图.png` 或直接给路径,模型自动调 `read_image` |
| 非多模态 + 刚截图/复制 | 截图后直接提问「分析这张截图」,模型自动调 `read_clipboard_image` 读系统剪贴板 |
## 激活与触发
### 激活(一次性)
```
/plugins install D:\AIGC\Plugin\kimi-eyes # 1. 安装
/reload # 2. 启用(或 /new 新开会话)
```
启用后 MCP 服务器随每个会话自动启动,没有单独的「开启功能」步骤。验证方式:
- `/plugins list` → kimi-eyes 状态应为 enabled
- `/mcp` → kimi-eyes 服务器应显示 connected
- `/plugins info kimi-eyes` → 应无诊断错误
### 触发(日常,全自动)
插件是**被动式**的:`SYSTEM.md` 为模型植入规则,模型在合适时机自动调用工具,无需手动操作:
| 信号(消息里的任何一项) | 模型自动行为 |
| --- | --- |
| 图片格式路径或 `@` 引用(`.png/.jpg/.jpeg/.webp/.gif/.bmp`) | **硬触发**:无条件调 `read_image(path)`,与提问语料无关 |
| 消息里有你无法解读的媒体内容(如粘贴的图片) | 忽略该媒体 part,自动调 `read_clipboard_image()` 读剪贴板(粘贴后剪贴板仍保留原图) |
| 提问语义涉及图像内容:图 / 截图 / 照片 / 界面 / 图表 / 验证码 / OCR 等,但无路径 | 自动调 `read_clipboard_image()` |
| 模型原生支持 `image_in`(多模态) | 跳过全部规则,原生看图,不调工具 |
**触发不依赖固定语料**——用户不需要说「分析这个图片」。图片格式路径是**无条件硬触发**;拿不准时模型会倾向于调工具而不是瞎猜(SYSTEM.md 里的明确规则)。
**关于模型能力判断**:KimiCode 不向插件暴露「当前模型是否多模态」的信号,本插件提供两层判断:
- **代码级(推荐)**:在配置里声明 `mainModel`(setup 向导最后一步,或环境变量 `VISION_MAIN_MODEL`),插件用内置的 models.dev 数据库判断——命中且支持识图 → 工具直接提示「你是多模态模型,直接粘贴看图」;命中且纯文本 → 正常走工具
- **引导级**:未声明 `mainModel` 时,靠 SYSTEM.md 的显式跳过规则(模型自我识别)
两层都是 fail-safe:判断错误最坏只是多一次外部调用(多模态误调)或漏调(文本模型漏调可用路径/语义信号补上)。若想硬性关闭,多模态场景可直接 `/plugins disable kimi-eyes`。
**首次调用会弹一次审批**(MCP 工具权限):选 *Approve for this session* 本会话免问;想永久免审批,在 `~/.kimi-code/config.toml` 添加:
```toml
[[permission.rules]]
decision = "allow"
pattern = "mcp__kimi-eyes__*"
```
触发成功的标志:回答前 TUI 出现工具调用记录,模型随后基于返回的描述作答。若模型未自动调用(例如只贴了路径未提问),可直接命令:「调用 read_image 工具分析 D:\xxx.png」。
## 环境变量(可选,覆盖配置文件)
| 变量 | 说明 | 优先级 |
| --- | --- | --- |
| `VISION_API_PROTOCOL` | `openai` 或 `anthropic`,强制指定协议 | 高于配置文件 |
| `VISION_API_KEY` | API Key | 高于配置文件 |
| `VISION_API_URL` | BaseUrl | 高于配置文件 |
| `VISION_MODEL` | 模型名 | 高于配置文件 |
| `VISION_MAIN_MODEL` | 当前主模型名(可选),用于触发判断 | 高于配置文件 |
| `VISION_MAX_TOKENS` | 视觉 API 返回最大 token 数(默认 1024) | — |
| `VISION_FETCH_TIMEOUT_MS` | 请求超时毫秒(默认 60000) | — |
> 环境变量与 `config.json` 同名配置冲突时,环境变量优先;两者都没配时工具会返回明确错误并提示先运行 `setup.mjs`。
> 环境变量可沿用 opencode-vision 的迁移习惯——变量名完全兼容。
## 协议说明
| | OpenAI 兼容 | Anthropic |
| --- | --- | --- |
| 请求端点 | `{BaseUrl}/chat/completions`(自动补 `/v1`) | `{BaseUrl}/v1/messages` |
| 鉴权 | `Authorization: Bearer <Key>` | `x-api-key: <Key>` + `anthropic-version: 2023-06-01` |
| 图片传法 | `image_url` + base64 data URL | `source: {type:"base64"}` |
| 模型列表 | `GET {BaseUrl}/models` | `GET {BaseUrl}/v1/models`(官方无此端点,失败回退手动输入) |
## 模型能力数据库(models.dev)
插件内置一份从 [models.dev](https://models.dev) 同步的精简能力库 `mcp/models-db.json`(当前收录 279+ 模型,标注每个模型是否支持图片输入,约 19KB),用于两处:
- 向导选择视觉模型时的**精确标注**(✓视觉 / 文本),替代纯关键词猜测
- 声明 `mainModel` 后**判断当前主模型是否多模态**,实现代码级触发开关
**打包时同步**(数据会更新,建议每次发布前运行):
```
node scripts/sync-models.mjs
```
- 数据源:`https://models.dev/models.json`
- 走代理:设置 `HTTPS_PROXY`,例如 `HTTPS_PROXY=http://127.0.0.1:7897 node scripts/sync-models.mjs`
- 可选参数:`--timeout <秒>`(默认 180)、`--out <路径>`(默认 `mcp/models-db.json`)、`--endpoint <URL>`
- 模型匹配策略:精确 id → `provider/模型名` 后缀 → 大小写不敏感的名称匹配
- **未收录的模型**:查询返回未知——向导回退关键词猜测(★疑似),触发判断回退 SYSTEM.md 引导,不影响使用
- **手动特例**:models.dev 未收录但确认支持识图的模型(如 `k3-256k`、`kimi-for-coding`),在 `scripts/sync-models.mjs` 的 `EXTRA_ENTRIES` 里维护,每次同步自动合入、不会被覆盖
## 工具
| 工具 | 参数 | 说明 |
| --- | --- | --- |
| `read_image` | `path`(必填)、`prompt`(可选) | 校验文件为图片(扩展名 + 魔数)后调 VLM |
| `read_clipboard_image` | `prompt`(可选) | 从系统剪贴板取图存临时文件后调 VLM,用完即删 |
剪贴板取图依赖平台工具:Windows 用 PowerShell(内置)、macOS 需 `pngpaste`(`brew install pngpaste`)、Linux 需 `wl-paste`(Wayland)或 `xclip`(X11)。
## 故障排查
- **工具返回「Vision API is not configured」** → 运行 `node setup.mjs`,或设置 `VISION_API_KEY` / `VISION_API_URL` / `VISION_MODEL`
- **模型列表拉取失败**(404/401)→ 向导自动回退手动输入;若服务端不支持 `/models`,直接输入模型名即可
- **视觉验证失败** → 换一个真正支持图片输入的模型(参考服务商文档,如 `qwen-vl-max`、`glm-4v`、`gpt-4o`、`claude-3-5-sonnet` 等)
- **`read_clipboard_image` 报错** → 确认剪贴板里确实有图片(先 Ctrl+C 复制或 Win+Shift+S 截图);macOS/Linux 检查上述平台工具是否安装
- **安装后工具不生效** → 确认插件已启用(`/plugins list`),并运行过 `/reload` 或新开会话
## 安全说明
- `config.json` 以明文保存你的 API Key(类 Unix 已设 `600` 权限);请勿将该文件提交到版本库
- `read_clipboard_image` 会读取系统剪贴板内容——剪贴板里可能刚复制过敏感信息。该工具调用会经过审批流,由你决定何时触发
- 本项目不内置任何密钥,视觉请求只发往你配置的 BaseUrl
## 局限
- 不做子代理委托方案:KimiCode 子代理的 `model_preference` 只能选择 primary/secondary,无法像 opencode 那样指定一个具体视觉模型,收益有限
- 不做 `UserPromptSubmit` hook 兜底:当前 `SYSTEM.md` 引导已覆盖常规场景;若你的 TUI 环境下粘贴/引用行为异常,可再评估 hook
## License
MIT
+27
View File
@@ -0,0 +1,27 @@
# Kimi Eyes — Vision Assist Rules
**If your model supports image input (`image_in`) natively, ignore every rule below —
you can see images directly, so never call these tools.**
For models **without** native image input, the signals below are **hard triggers**:
call a vision tool whenever any of them appears, regardless of how the user phrased
their request.
1. **Image-format path / `@` reference (hard trigger, unconditional)** — any path
ending in `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, or `.bmp` (including an `@`
file reference to such a file) → call `mcp__kimi-eyes__read_image` with that
path. Pass `prompt` when the user has a specific question.
2. **Media content you cannot interpret** — a pasted image or any media part you
cannot read → ignore that part and call
`mcp__kimi-eyes__read_clipboard_image` (the image is almost always still in the
system clipboard).
3. **Wording implies an image, no path** — the user refers to "this", "the
screenshot", "the UI/interface", "the chart", "the photo", asks for OCR / CAPTCHA
reading, or otherwise implies image content, with no path and no visible media
part → call `mcp__kimi-eyes__read_clipboard_image`.
When in doubt, call a tool rather than guessing blindly about the image.
If a tool reports that the vision API is not configured, tell the user to run
`node setup.mjs` inside the kimi-eyes plugin directory (or set the
`VISION_API_KEY` / `VISION_API_URL` / `VISION_MODEL` environment variables).
+21
View File
@@ -0,0 +1,21 @@
{
"name": "kimi-eyes",
"version": "1.0.0",
"description": "Give non-multimodal models vision: analyze local images and clipboard screenshots through a user-configured VLM API (OpenAI-compatible or Anthropic protocol).",
"keywords": ["vision", "image", "screenshot", "multimodal", "vlm"],
"author": "kimi-eyes",
"license": "MIT",
"interface": {
"displayName": "Kimi Eyes",
"shortDescription": "视觉辅助:让非多模态模型也能分析图片/截图",
"longDescription": "读取本地图片或剪贴板截图,调用用户自配的多模态 API(OpenAI 兼容或 Anthropic 协议)返回描述。多模态模型不受影响,粘贴即原生看图。"
},
"systemPromptPath": "./SYSTEM.md",
"mcpServers": {
"kimi-eyes": {
"command": "node",
"args": ["./mcp/server.mjs"],
"cwd": "./"
}
}
}
+1
View File
@@ -0,0 +1 @@
{"zhipuai/glm-4.6v":{"name":"GLM-4.6V","image":true},"zhipuai/glm-5":{"name":"GLM-5","image":false},"zhipuai/glm-4.5-air":{"name":"GLM-4.5-Air","image":false},"zhipuai/glm-5.1":{"name":"GLM-5.1","image":false},"zhipuai/glm-4.7-flash":{"name":"GLM-4.7-Flash","image":false},"zhipuai/glm-5.2":{"name":"GLM-5.2","image":false},"zhipuai/glm-4.7-flashx":{"name":"GLM-4.7-FlashX","image":false},"zhipuai/glm-4.6":{"name":"GLM-4.6","image":false},"zhipuai/glm-4.5":{"name":"GLM-4.5","image":false},"zhipuai/glm-4.5v":{"name":"GLM-4.5V","image":true},"zhipuai/glm-4.7":{"name":"GLM-4.7","image":false},"zhipuai/glm-5-turbo":{"name":"GLM-5-Turbo","image":false},"zhipuai/glm-5v-turbo":{"name":"GLM-5V-Turbo","image":true},"zhipuai/glm-4.5-flash":{"name":"GLM-4.5-Flash","image":false},"microsoft/mai-code-1-flash":{"name":"MAI-Code-1-Flash","image":false},"cohere/command-r7b-arabic-02-2025":{"name":"Command R7B Arabic","image":false},"cohere/command-r-08-2024":{"name":"Command R","image":false},"cohere/command-a-plus-05-2026":{"name":"Command A Plus","image":true},"cohere/command-a-translate-08-2025":{"name":"Command A Translate","image":false},"cohere/c4ai-aya-expanse-8b":{"name":"Aya Expanse 8B","image":false},"cohere/c4ai-aya-vision-32b":{"name":"Aya Vision 32B","image":true},"cohere/command-a-03-2025":{"name":"Command A","image":false},"cohere/command-r-plus-08-2024":{"name":"Command R+","image":false},"cohere/command-a-reasoning-08-2025":{"name":"Command A Reasoning","image":false},"cohere/command-a-vision-07-2025":{"name":"Command A Vision","image":true},"cohere/command-r7b-12-2024":{"name":"Command R7B","image":false},"cohere/c4ai-aya-vision-8b":{"name":"Aya Vision 8B","image":true},"cohere/north-mini-code-1-0":{"name":"North Mini Code","image":false},"cohere/c4ai-aya-expanse-32b":{"name":"Aya Expanse 32B","image":false},"nvidia/mistral-nemotron":{"name":"Mistral Nemotron","image":false},"nvidia/nemotron-nano-12b-v2-vl":{"name":"Nemotron Nano 12B v2 VL","image":true},"nvidia/nemotron-3-nano-30b-a3b":{"name":"Nemotron 3 Nano 30B A3B","image":false},"nvidia/llama-3.3-nemotron-super-49b-v1.5":{"name":"Llama 3.3 Nemotron Super 49B v1.5","image":false},"nvidia/llama-3.1-nemotron-safety-guard-8b-v3":{"name":"Llama 3.1 Nemotron Safety Guard 8B v3","image":false},"nvidia/llama-3.3-nemotron-super-49b-v1":{"name":"Llama 3.3 Nemotron Super 49B v1","image":false},"nvidia/llama-3.1-nemotron-70b-instruct":{"name":"Llama 3.1 Nemotron 70B Instruct","image":false},"nvidia/nemotron-3-super-120b-a12b":{"name":"Nemotron 3 Super 120B A12B","image":false},"nvidia/nemotron-3-content-safety":{"name":"Nemotron 3 Content Safety","image":false},"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning":{"name":"Nemotron 3 Nano Omni 30B A3B Reasoning","image":true},"nvidia/llama-3.1-nemotron-ultra-253b":{"name":"Llama 3.1 Nemotron Ultra 253B","image":false},"nvidia/nemotron-voicechat":{"name":"Nemotron VoiceChat","image":false},"nvidia/llama-nemotron-embed-vl-1b-v2":{"name":"Llama Nemotron Embed VL 1B v2","image":true},"nvidia/nemotron-content-safety-reasoning-4b":{"name":"Nemotron Content Safety Reasoning 4B","image":false},"nvidia/nemotron-3-ultra-550b-a55b":{"name":"Nemotron 3 Ultra 550B A55B","image":false},"nvidia/llama-nemotron-rerank-vl-1b-v2":{"name":"Llama Nemotron Rerank VL 1B v2","image":true},"nvidia/nemotron-mini-4b-instruct":{"name":"Nemotron Mini 4B Instruct","image":false},"nvidia/nemotron-nano-9b-v2":{"name":"Nemotron Nano 9B v2","image":false},"nvidia/nemotron-cascade-2-30b-a3b":{"name":"Nemotron Cascade 2 30B A3B","image":false},"nvidia/nemotron-3.5-content-safety":{"name":"Nemotron 3.5 Content Safety","image":true},"google/gemini-2.5-computer-use-preview-10-2025":{"name":"Gemini 2.5 Computer Use Preview","image":true},"google/deep-research-preview-04-2026":{"name":"Gemini Deep Research Preview","image":true},"google/gemini-3.1-flash-tts-preview":{"name":"Gemini 3.1 Flash TTS Preview","image":false},"google/gemini-flash-latest":{"name":"Gemini Flash Latest","image":true},"google/gemini-embedding-2":{"name":"Gemini Embedding 2","image":true},"google/lyria-3-pro-preview":{"name":"Lyria 3 Pro Preview","image":true},"google/gemini-3.5-flash":{"name":"Gemini 3.5 Flash","image":true},"google/gemini-2.5-flash":{"name":"Gemini 2.5 Flash","image":true},"google/gemini-3.5-flash-lite":{"name":"Gemini 3.5 Flash Lite","image":true},"google/lyria-3-clip-preview":{"name":"Lyria 3 Clip Preview","image":true},"google/gemini-omni-flash-preview":{"name":"Gemini Omni Flash Preview","image":true},"google/veo-3.1-generate-preview":{"name":"Veo 3.1 Preview","image":true},"google/gemini-2.5-pro-tts":{"name":"Gemini 2.5 Pro TTS","image":false},"google/deep-research-max-preview-04-2026":{"name":"Deep Research Max Preview","image":true},"google/gemini-3-pro-image-preview":{"name":"Nano Banana Pro","image":true},"google/gemini-3.1-flash-lite-preview":{"name":"Gemini 3.1 Flash Lite Preview","image":true},"google/gemini-2.5-flash-tts":{"name":"Gemini 2.5 Flash TTS","imLine truncated
+241
View File
@@ -0,0 +1,241 @@
// kimi-eyes MCP stdio server — zero-dependency.
// Speaks JSON-RPC 2.0 over LSP-style framing (Content-Length headers) on stdio.
import fs from 'node:fs';
import path from 'node:path';
import {
VERSION,
getConfig,
requireConfigured,
callVLM,
sniffImage,
clipboardImageToTemp,
lookupModelCapability,
} from './vision.mjs';
const TOOLS = [
{
name: 'read_image',
description:
'Analyze a local image file using the configured vision API and return a text description. ' +
'Use when the user provides an image path or an @ file reference and your model cannot see images directly.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Absolute path to a local image file (png, jpg, jpeg, webp, gif, bmp).',
},
prompt: {
type: 'string',
description: 'Optional instruction describing what to analyze (default: describe the image).',
},
},
required: ['path'],
},
},
{
name: 'read_clipboard_image',
description:
'Capture the image currently in the system clipboard (e.g. a screenshot) and analyze it with the ' +
'configured vision API. Use when the user says they just took a screenshot or copied an image and no file path is given.',
inputSchema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'Optional instruction describing what to analyze (default: describe the image).',
},
},
},
},
];
// ---------------------------------------------------------------------------
// Tools
// ---------------------------------------------------------------------------
/**
* When the user declares their main model (config.mainModel) and the models.dev
* database says it supports image input, the tools are unnecessary — the model can
* see images natively. Refuse with a hint instead of wasting a VLM call.
*/
function assertNeedsExternalVision(cfg) {
if (!cfg.mainModel) return;
const cap = lookupModelCapability(cfg.mainModel);
if (cap.known && cap.image) {
throw new Error(
`Model "${cfg.mainModel}" supports native image input (per the models.dev database), ` +
`so the kimi-eyes tools are not needed. Paste the image directly and the model will see it. ` +
`If you still want kimi-eyes active, remove "mainModel" from the kimi-eyes config.`
);
}
}
async function readImageTool(args) {
const p = args?.path;
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Missing required parameter: path');
}
const cfg = getConfig();
assertNeedsExternalVision(cfg);
const abs = path.resolve(p.trim());
let stat;
try {
stat = fs.statSync(abs);
} catch {
throw new Error(`File not found: ${abs}`);
}
if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
const img = sniffImage(abs);
if (!img) {
throw new Error(
`Unsupported or invalid image file: ${abs} (supported: png, jpg, jpeg, webp, gif, bmp)`
);
}
requireConfigured(cfg);
return callVLM({ ...img, prompt: args?.prompt }, cfg);
}
async function readClipboardImageTool(args) {
const cfg = getConfig();
assertNeedsExternalVision(cfg);
requireConfigured(cfg);
let tmp = null;
try {
tmp = clipboardImageToTemp();
const img = sniffImage(tmp);
if (!img) throw new Error('Clipboard does not contain a valid image.');
return callVLM({ ...img, prompt: args?.prompt }, cfg);
} finally {
if (tmp) {
try {
fs.unlinkSync(tmp);
} catch {
// best-effort cleanup
}
}
}
}
async function callTool(name, args) {
if (name === 'read_image') return readImageTool(args);
if (name === 'read_clipboard_image') return readClipboardImageTool(args);
throw new Error(`Unknown tool: ${name}`);
}
// ---------------------------------------------------------------------------
// JSON-RPC over stdio (MCP framing)
// ---------------------------------------------------------------------------
function send(obj) {
const body = Buffer.from(JSON.stringify(obj), 'utf8');
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
process.stdout.write(body);
}
function sendError(id, code, message) {
send({ jsonrpc: '2.0', id, error: { code, message } });
}
async function handleRequest(msg) {
const { id, method, params } = msg;
try {
switch (method) {
case 'initialize':
send({
jsonrpc: '2.0',
id,
result: {
protocolVersion: params?.protocolVersion || '2025-06-18',
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'kimi-eyes', version: VERSION },
},
});
return;
case 'ping':
send({ jsonrpc: '2.0', id, result: {} });
return;
case 'tools/list':
send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
return;
case 'tools/call': {
const name = params?.name;
const args = params?.arguments || {};
try {
const text = await callTool(name, args);
send({
jsonrpc: '2.0',
id,
result: { content: [{ type: 'text', text }] },
});
} catch (err) {
send({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: err.message || String(err) }],
isError: true,
},
});
}
return;
}
default:
sendError(id, -32601, `Method not found: ${method}`);
}
} catch (err) {
sendError(id, -32603, err.message || String(err));
}
}
function handleMessage(msg) {
if (!msg || typeof msg !== 'object' || typeof msg.method !== 'string') return;
const isRequest = msg.id !== undefined && msg.id !== null;
if (isRequest) {
handleRequest(msg).catch((err) => sendError(msg.id, -32603, err.message || String(err)));
}
// Notifications (e.g. notifications/initialized) are intentionally ignored.
}
// --- framing parser ---------------------------------------------------------
let buf = Buffer.alloc(0);
function tryParseFrame() {
const headerEnd = buf.indexOf('\r\n\r\n');
if (headerEnd === -1) return null;
const header = buf.subarray(0, headerEnd).toString('ascii');
const m = /Content-Length:\s*(\d+)/i.exec(header);
if (!m) return null;
const len = Number(m[1]);
const bodyStart = headerEnd + 4;
if (buf.length < bodyStart + len) return null;
const body = buf.subarray(bodyStart, bodyStart + len).toString('utf8');
buf = buf.subarray(bodyStart + len);
return body;
}
function pump() {
for (;;) {
const body = tryParseFrame();
if (body === null) break;
let msg;
try {
msg = JSON.parse(body);
} catch (err) {
console.error(`[kimi-eyes] invalid JSON frame: ${err.message}`);
continue;
}
handleMessage(msg);
}
}
process.stdin.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
pump();
});
process.stdin.on('end', () => process.exit(0));
console.error(`[kimi-eyes] MCP server started (v${VERSION}, node ${process.version})`);
+344
View File
@@ -0,0 +1,344 @@
// kimi-eyes vision core — shared by the MCP server and the setup wizard.
// Zero-dependency. Node 18+ (native fetch, AbortSignal.timeout).
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const MODELS_DB_PATH = path.join(__dirname, 'models-db.json');
export const VERSION = '1.0.0';
export const EXT_MEDIA = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
bmp: 'image/bmp',
};
export const DEFAULT_PROMPT = 'Describe this image in detail.';
// A minimal 1x1 PNG used for setup verification.
export const ONE_PX_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
// Resolution order: environment variables (VISION_*) > config.json.
// protocol resolves to 'openai' by default when nothing is set.
export function getConfig(env = process.env) {
const home = env.KIMI_CODE_HOME || path.join(os.homedir(), '.kimi-code');
const cfgPath = path.join(home, 'kimi-eyes', 'config.json');
let file = {};
try {
file = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
} catch {
// no config file yet — fine
}
const envProtocol = (env.VISION_API_PROTOCOL || '').toLowerCase();
const protocol =
envProtocol === 'openai' || envProtocol === 'anthropic'
? envProtocol
: file.protocol || 'openai';
return {
configPath: cfgPath,
protocol,
apiKey: env.VISION_API_KEY || file.apiKey || '',
baseUrl: env.VISION_API_URL || file.baseUrl || '',
model: env.VISION_MODEL || file.model || '',
mainModel: env.VISION_MAIN_MODEL || file.mainModel || '',
};
}
export function checkConfig(cfg) {
const missing = [];
if (!cfg.apiKey) missing.push('VISION_API_KEY');
if (!cfg.baseUrl) missing.push('VISION_API_URL');
if (!cfg.model) missing.push('VISION_MODEL');
return missing;
}
export function requireConfigured(cfg) {
const missing = checkConfig(cfg);
if (missing.length > 0) {
throw new Error(
`Vision API is not configured. Missing: ${missing.join(', ')}. ` +
`Run "node setup.mjs" in the kimi-eyes plugin directory (or set the environment variables).`
);
}
}
// ---------------------------------------------------------------------------
// Models.dev capability database (see scripts/sync-models.mjs)
// ---------------------------------------------------------------------------
let modelsDbCache = null; // null = not loaded yet, undefined = unavailable
/** Load the slimmed models.dev database; returns null if unavailable. */
export function loadModelsDb() {
if (modelsDbCache !== null) return modelsDbCache;
try {
modelsDbCache = JSON.parse(fs.readFileSync(MODELS_DB_PATH, 'utf8'));
} catch {
modelsDbCache = undefined;
}
return modelsDbCache;
}
/**
* Look up whether a model name supports image input, using the models.dev cache.
* Matching: exact id → provider/id suffix → case-insensitive display name.
* @returns {{known: boolean, image: boolean}}
*/
export function lookupModelCapability(modelName) {
const db = loadModelsDb();
if (!db) return { known: false, image: false };
const n = String(modelName || '').trim();
if (!n) return { known: false, image: false };
if (db[n]) return { known: true, image: !!db[n].image };
const lower = n.toLowerCase();
for (const [id, m] of Object.entries(db)) {
if (id.toLowerCase().endsWith(`/${lower}`)) {
return { known: true, image: !!m.image };
}
if (typeof m.name === 'string' && m.name.toLowerCase() === lower) {
return { known: true, image: !!m.image };
}
}
return { known: false, image: false };
}
/** Text tag used by the setup wizard: exact capability when known, keyword hint otherwise. */
export function modelCapabilityTag(modelName, visionHintRe) {
const cap = lookupModelCapability(modelName);
if (cap.known) return cap.image ? '✓视觉' : '文本';
return visionHintRe && visionHintRe.test(modelName) ? '★疑似' : '';
}
// ---------------------------------------------------------------------------
// Endpoints
// ---------------------------------------------------------------------------
export function openaiEndpoint(baseUrl) {
let b = String(baseUrl || '').trim().replace(/\/+$/, '');
if (!b) return '';
if (/\/chat\/completions$/i.test(b)) return b;
if (/\/v\d+$/i.test(b)) return `${b}/chat/completions`;
return `${b}/v1/chat/completions`;
}
export function anthropicEndpoint(baseUrl) {
let b = String(baseUrl || '').trim().replace(/\/+$/, '');
if (!b) return '';
if (/\/v1\/messages$/i.test(b)) return b;
if (/\/v\d+$/i.test(b)) return `${b}/messages`;
return `${b}/v1/messages`;
}
export function modelsEndpoint(baseUrl, protocol) {
let b = String(baseUrl || '').trim().replace(/\/+$/, '');
if (!b) return '';
if (/\/v\d+$/i.test(b)) return `${b}/models`;
return `${b}/v1/models`;
}
// ---------------------------------------------------------------------------
// Vision API calls
// ---------------------------------------------------------------------------
const FETCH_TIMEOUT_MS = Number(process.env.VISION_FETCH_TIMEOUT_MS) || 60000;
const MAX_TOKENS = Number(process.env.VISION_MAX_TOKENS) || 1024;
/**
* Call the user-configured VLM with a base64 image.
* @param {{imageBase64:string, mediaType:string, prompt?:string}} input
* @param {{protocol:string, apiKey:string, baseUrl:string, model:string}} cfg
* @returns {Promise<string>} text description
*/
export async function callVLM({ imageBase64, mediaType, prompt }, cfg) {
requireConfigured(cfg);
if (cfg.protocol === 'anthropic') {
return callAnthropic(imageBase64, mediaType, prompt, cfg);
}
return callOpenAI(imageBase64, mediaType, prompt, cfg);
}
async function callOpenAI(imageBase64, mediaType, prompt, cfg) {
const endpoint = openaiEndpoint(cfg.baseUrl);
const body = {
model: cfg.model,
max_tokens: MAX_TOKENS,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt || DEFAULT_PROMPT },
{ type: 'image_url', image_url: { url: `data:${mediaType};base64,${imageBase64}` } },
],
},
],
};
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cfg.apiKey}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
throw new Error(`Vision API HTTP ${res.status}: ${(await safeText(res)).slice(0, 300)}`);
}
const data = await res.json();
const content = data?.choices?.[0]?.message?.content;
if (typeof content === 'string' && content.trim()) return content.trim();
if (Array.isArray(content)) {
const text = content
.filter((p) => p && p.type === 'text' && typeof p.text === 'string')
.map((p) => p.text)
.join('\n')
.trim();
if (text) return text;
}
throw new Error(`Unexpected response from vision API: ${JSON.stringify(data).slice(0, 300)}`);
}
async function callAnthropic(imageBase64, mediaType, prompt, cfg) {
const endpoint = anthropicEndpoint(cfg.baseUrl);
const body = {
model: cfg.model,
max_tokens: MAX_TOKENS,
messages: [
{
role: 'user',
content: [
{ type: 'image', source: { type: 'base64', media_type: mediaType, data: imageBase64 } },
{ type: 'text', text: prompt || DEFAULT_PROMPT },
],
},
],
};
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': cfg.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
throw new Error(`Vision API HTTP ${res.status}: ${(await safeText(res)).slice(0, 300)}`);
}
const data = await res.json();
const texts = (Array.isArray(data?.content) ? data.content : [])
.filter((p) => p && p.type === 'text' && typeof p.text === 'string')
.map((p) => p.text)
.join('\n')
.trim();
if (texts) return texts;
throw new Error(`Unexpected response from vision API: ${JSON.stringify(data).slice(0, 300)}`);
}
async function safeText(res) {
try {
return await res.text();
} catch {
return '(could not read response body)';
}
}
// ---------------------------------------------------------------------------
// Image sniffing
// ---------------------------------------------------------------------------
/**
* Validate that a file is a supported image and return base64 + media type.
* @returns {{imageBase64:string, mediaType:string}|null}
*/
export function sniffImage(absPath) {
const ext = path.extname(absPath).toLowerCase().replace('.', '');
if (!EXT_MEDIA[ext]) return null;
let buf;
try {
buf = fs.readFileSync(absPath);
} catch {
return null;
}
if (buf.length === 0) return null;
const ascii4 = buf.toString('ascii', 0, 4);
let ok = false;
if (ext === 'png') ok = buf.subarray(0, 8).toString('hex').startsWith('89504e47');
else if (ext === 'jpg' || ext === 'jpeg') ok = buf.subarray(0, 3).toString('hex') === 'ffd8ff';
else if (ext === 'gif') ok = ascii4 === 'GIF8';
else if (ext === 'bmp') ok = ascii4 === 'BM';
else if (ext === 'webp') {
ok = buf.length > 12 && ascii4 === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP';
}
if (!ok) return null;
return { imageBase64: buf.toString('base64'), mediaType: EXT_MEDIA[ext] };
}
// ---------------------------------------------------------------------------
// Clipboard capture
// ---------------------------------------------------------------------------
/**
* Grab the image currently in the system clipboard and save it to a temp PNG.
* @returns {string} path to the temp PNG file
*/
export function clipboardImageToTemp() {
const out = path.join(
os.tmpdir(),
`kimi-eyes-clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`
);
if (process.platform === 'win32') {
const script = [
'Add-Type -AssemblyName System.Windows.Forms;',
'$img = [System.Windows.Forms.Clipboard]::GetImage();',
"if ($img -eq $null) { Write-Error 'No image in clipboard'; exit 1 };",
`$img.Save('${out.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
].join(' ');
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
stdio: 'pipe',
timeout: 20000,
});
} else if (process.platform === 'darwin') {
try {
execFileSync('pngpaste', [out], { stdio: 'pipe', timeout: 20000 });
} catch (e) {
throw new Error('Clipboard capture failed. Install "pngpaste" (brew install pngpaste). ' + e.message);
}
} else {
try {
const png = execFileSync('wl-paste', ['-t', 'image/png', '--no-newline'], {
stdio: 'pipe',
timeout: 15000,
});
fs.writeFileSync(out, png);
} catch (e1) {
try {
const png = execFileSync('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o'], {
stdio: 'pipe',
timeout: 15000,
});
fs.writeFileSync(out, png);
} catch (e2) {
throw new Error(
'Clipboard capture failed. Need "wl-paste" (Wayland) or "xclip" (X11). ' +
`${e1.message}; ${e2.message}`
);
}
}
}
return out;
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "kimi-eyes",
"version": "1.0.0",
"description": "Give non-multimodal models in Kimi Code the ability to analyze images and screenshots via a user-configured VLM (OpenAI-compatible or Anthropic protocol).",
"keywords": [
"kimi",
"kimi-code",
"vision",
"image",
"screenshot",
"multimodal",
"vlm",
"mcp",
"plugin"
],
"license": "MIT",
"author": "billowliu2",
"type": "module",
"bin": {
"kimi-eyes": "setup.mjs"
},
"files": [
"mcp/",
"scripts/",
"SYSTEM.md",
"kimi.plugin.json",
"setup.mjs",
"README.md",
"README.en.md",
"LICENSE"
],
"engines": {
"node": ">=18"
},
"scripts": {
"setup": "node setup.mjs",
"sync:models": "node scripts/sync-models.mjs",
"prepublishOnly": "node scripts/sync-models.mjs"
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
// kimi-eyes — sync the models.dev capability database into a slim local cache.
// Run this when packaging/releasing the plugin:
//
// node scripts/sync-models.mjs
//
// Options:
// --timeout <seconds> curl timeout (default 180)
// --out <path> output file (default ./mcp/models-db.json)
// --endpoint <url> override the source URL (default https://models.dev/models.json)
//
// Proxy: the script uses the HTTPS_PROXY / https_proxy environment variable if set
// (for users behind Clash/V2Ray etc.), falling back to a direct connection.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
const DEFAULT_ENDPOINT = 'https://models.dev/models.json';
const DEFAULT_OUT = path.join('mcp', 'models-db.json');
// Models known to support image input but missing from models.dev.
// Maintained manually — merged into the db on every sync so re-syncs never drop them.
const EXTRA_ENTRIES = {
'k3-256k': { name: 'K3-256k', image: true },
'kimi-k3-256k': { name: 'K3-256k', image: true },
'moonshot/k3-256k': { name: 'K3-256k', image: true },
'kimi-for-coding': { name: 'Kimi For Coding', image: true },
'moonshotai/kimi-for-coding': { name: 'Kimi For Coding', image: true },
'kimi-for-coding-highspeed': { name: 'Kimi For Coding Highspeed', image: true },
'moonshotai/kimi-for-coding-highspeed': { name: 'Kimi For Coding Highspeed', image: true },
};
function parseArgs(argv) {
const args = { timeout: 180, out: DEFAULT_OUT, endpoint: DEFAULT_ENDPOINT };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--timeout') args.timeout = Number(argv[++i]) || 180;
else if (argv[i] === '--out') args.out = argv[++i];
else if (argv[i] === '--endpoint') args.endpoint = argv[++i];
}
return args;
}
function download(endpoint, outFile, timeout) {
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
const curl = ['-sSL', '--max-time', String(timeout), '-o', outFile, endpoint];
const args = proxy ? ['-x', proxy, ...curl] : curl;
try {
execFileSync('curl', args, { stdio: 'inherit', timeout: (timeout + 30) * 1000 });
} catch (err) {
throw new Error(
`Failed to download ${endpoint}${proxy ? ` via proxy ${proxy}` : ''}. ` +
`If you are behind a proxy, set HTTPS_PROXY (e.g. HTTPS_PROXY=http://127.0.0.1:7897). ` +
`(${err.message.split('\n')[0]})`
);
}
}
const args = parseArgs(process.argv.slice(2));
const tmpFile = path.join(os.tmpdir(), `models-dev-${Date.now()}.json`);
console.log(`[sync-models] downloading ${args.endpoint} ...`);
download(args.endpoint, tmpFile, args.timeout);
const raw = JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
fs.rmSync(tmpFile, { force: true });
throw new Error(`Unexpected response shape from ${args.endpoint} (expected a flat model map).`);
}
// Slim down: keep only what the plugin needs.
const slim = {};
let imageCount = 0;
for (const [id, m] of Object.entries(raw)) {
if (!m || typeof m !== 'object') continue;
const input = Array.isArray(m.modalities?.input) ? m.modalities.input : [];
const image = input.includes('image');
if (image) imageCount++;
slim[id] = { name: typeof m.name === 'string' ? m.name : id, image };
}
// Merge manual extras (they override the downloaded data on id collisions).
for (const [id, m] of Object.entries(EXTRA_ENTRIES)) {
slim[id] = m;
if (m.image) imageCount++;
}
fs.rmSync(tmpFile, { force: true });
fs.mkdirSync(path.dirname(args.out), { recursive: true });
fs.writeFileSync(args.out, `${JSON.stringify(slim)}\n`, 'utf8');
const kb = (fs.statSync(args.out).size / 1024).toFixed(0);
console.log(
`[sync-models] wrote ${Object.keys(slim).length} models (${imageCount} support image input) to ${args.out} (${kb} KB)`
);
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env node
// kimi-eyes setup wizard — interactive one-time configuration.
// Zero-dependency: raw-mode stdin input + native fetch.
//
// Flow: choose protocol → base URL → API key → pick multimodal model → verify → write config.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { callVLM, modelsEndpoint, ONE_PX_PNG_BASE64, modelCapabilityTag } from './mcp/vision.mjs';
// ---------------------------------------------------------------------------
// Raw-mode line input (supports masked echo)
// ---------------------------------------------------------------------------
let pipeLines = null;
function ensurePipeLines() {
if (!pipeLines) {
pipeLines = new Promise((resolve, reject) => {
let s = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => (s += c));
process.stdin.on('end', () => resolve(s.split(/\r?\n/)));
process.stdin.on('error', reject);
process.stdin.resume();
});
}
return pipeLines;
}
function ask(query, { hidden = false } = {}) {
const stdin = process.stdin;
if (!stdin.isTTY) {
// Non-TTY fallback (piped input in tests/CI): plain line read, no masking.
process.stdout.write(query);
return ensurePipeLines().then((lines) => lines.shift() ?? '');
}
process.stdout.write(query);
stdin.setRawMode(true);
stdin.resume();
return new Promise((resolve) => {
let val = '';
let esc = false;
const cleanup = () => {
stdin.setRawMode(false);
stdin.removeListener('data', onData);
process.stdout.write('\n');
};
const onData = (chunk) => {
for (const ch of chunk.toString('utf8')) {
if (esc) {
if ((ch >= 'A' && ch <= 'D') || ch === '~') esc = false;
continue;
}
if (ch === '\u001b') {
esc = true;
continue;
}
if (ch === '\r' || ch === '\n') {
cleanup();
resolve(val);
return;
}
if (ch === '\u0003') process.exit(130);
if (ch === '\u007f' || ch === '\b') {
if (val.length > 0) {
val = val.slice(0, -1);
process.stdout.write('\b \b');
}
continue;
}
val += ch;
process.stdout.write(hidden ? '*' : ch);
}
};
stdin.on('data', onData);
});
}
async function askRequired(query, { hidden = false, validate = null } = {}) {
for (;;) {
const v = (await ask(query, { hidden })).trim();
if (!v) {
console.log(' 输入不能为空,请重试。');
continue;
}
if (validate) {
const err = validate(v);
if (err) {
console.log(` ${err}`);
continue;
}
}
return v;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const VISION_HINTS =
/(vision|visual|vlm|multimodal|4o|omni|glm-4v|qwen-vl|qwen2.*?vl|internvl|minicpm|gpt-4v|gpt-5|claude-3|claude-sonnet|claude-opus|gemini)/i;
async function fetchModels(protocol, baseUrl, apiKey) {
const url = modelsEndpoint(baseUrl, protocol);
const headers =
protocol === 'anthropic'
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
: { Authorization: `Bearer ${apiKey}` };
const res = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const list = Array.isArray(data?.data)
? data.data.map((m) => m.id || m.name).filter((x) => typeof x === 'string' && x)
: [];
if (list.length === 0) throw new Error('接口未返回任何模型');
return [...new Set(list)];
}
async function chooseProtocol() {
console.log(' 选择视觉 API 协议:');
console.log(' [1] OpenAI 兼容 (Chat Completions)');
console.log(' [2] Anthropic (Messages API)');
for (;;) {
const v = (await ask(' 输入序号 [1/2]: ')).trim();
if (v === '1') return 'openai';
if (v === '2') return 'anthropic';
console.log(' 无效输入,请输入 1 或 2。');
}
}
async function chooseModel(protocol, baseUrl, apiKey) {
console.log(' 拉取模型列表 ...');
let models = null;
try {
models = await fetchModels(protocol, baseUrl, apiKey);
} catch (err) {
console.log(` 拉取模型列表失败 (${err.message}),将改为手动输入模型名。`);
}
if (models) {
const shown = models.slice(0, 50);
console.log(
` 可用模型 (✓视觉=支持识图 / 文本=纯文本 / ★疑似=未收录按关键词猜测): ` +
`${models.length > 50 ? `显示前 50 / 共 ${models.length}` : ''}`
);
shown.forEach((m, i) => {
const tag = modelCapabilityTag(m, VISION_HINTS);
console.log(` [${String(i + 1).padStart(3)}] ${m}${tag ? ` ${tag}` : ''}`);
});
console.log(' 输入序号选择模型,或直接输入自定义模型名。');
for (;;) {
const v = (await ask(' 模型: ')).trim();
if (v === '') {
console.log(' 输入不能为空。');
continue;
}
const idx = Number.parseInt(v, 10);
if (!Number.isNaN(idx) && idx >= 1 && idx <= shown.length) return shown[idx - 1];
return v; // treat as custom model name
}
}
return askRequired(' 模型名: ');
}
async function verifyModel(cfg) {
console.log(' 视觉验证:发送 1×1 测试图到模型 ...');
try {
const text = await callVLM(
{ imageBase64: ONE_PX_PNG_BASE64, mediaType: 'image/png', prompt: 'Reply with the single word OK.' },
cfg
);
console.log(` ✓ 通过,模型可接收图片 (返回: ${text.slice(0, 60)})`);
return true;
} catch (err) {
console.log(` ✗ 失败: ${err.message.slice(0, 200)}`);
return false;
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const home = process.env.KIMI_CODE_HOME || path.join(os.homedir(), '.kimi-code');
const configPath = path.join(home, 'kimi-eyes', 'config.json');
console.log('=== Kimi Eyes 配置向导 ===');
console.log(`配置将写入: ${configPath}\n`);
const protocol = await chooseProtocol();
const baseUrl = await askRequired(' 视觉 API BaseUrl (如 https://api.openai.com/v1): ', {
validate: (v) => (/^https?:\/\//i.test(v) ? null : 'BaseUrl 必须以 http:// 或 https:// 开头。'),
});
const apiKey = await askRequired(' API Key: ', { hidden: true });
const model = await chooseModel(protocol, baseUrl, apiKey);
const cfg = { protocol, baseUrl, apiKey, model };
console.log('');
for (let attempt = 0; attempt < 3; attempt++) {
if (await verifyModel(cfg)) break;
if (attempt === 2) {
console.log(' 连续验证失败,跳过验证(配置仍将写入)。');
break;
}
const act = (await ask(' [r] 重试 [c] 换模型 [s] 跳过验证: ')).trim().toLowerCase();
if (act === 'c') {
cfg.model = await chooseModel(protocol, baseUrl, apiKey);
continue;
}
if (act === 's') break;
// 'r' or anything else → retry the loop
}
const mainModel = (await ask(' 当前主模型名(可选,用于触发判断;留空跳过): ')).trim();
if (mainModel) cfg.mainModel = mainModel;
const dir = path.dirname(configPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
if (process.platform !== 'win32') fs.chmodSync(configPath, 0o600);
console.log('');
console.log(`✓ 配置已写入: ${configPath}`);
console.log(` protocol: ${cfg.protocol}`);
console.log(` baseUrl : ${cfg.baseUrl}`);
console.log(` model : ${cfg.model}`);
if (cfg.mainModel) console.log(` mainModel: ${cfg.mainModel} (触发判断用)`);
console.log('');
console.log('下一步:');
console.log(` 1. 安装插件: /plugins install ${process.cwd()}`);
console.log(' 2. 启用插件: /reload');
console.log(' 3. 开始使用: 非多模态模型下 @图片路径 或 截图后提问');
}
main().catch((err) => {
console.error(`\n[setup] 出错: ${err.message}`);
process.exit(1);
});