diff --git a/README.md b/README.md index 7ec5950..88d76e9 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,98 @@ # Opencode Vision Bridge Plugin -Этот плагин позволяет Opencode работать с изображениями, даже если активная модель не поддерживает мультимодальность. Он перехватывает изображения, отправляет их в указанную vision-модель для анализа, а затем заменяет изображение текстовым описанием в контексте диалога. +Этот плагин позволяет Opencode работать с изображениями, даже если активная модель не поддерживает мультимодальность. Он перехватывает изображения, отправляет их в vision-модель для анализа, а затем заменяет изображение текстовым описанием в контексте диалога. + +Распознавание выполняется через **Google Gemini API** (OpenAI-совместимый эндпоинт), модель по умолчанию — `gemini-2.5-flash`. Если активная модель сама умеет видеть изображения (помечена `"attachment": true` в конфиге), картинки передаются ей напрямую без распознавания. ## Требования -- Доступ к API-эндпоинту Ludmila AI (https://ai.totmin.ru/v1). -- Ключ API Ludmila AI. +- API-ключ Google Gemini. Получить бесплатно: [Google AI Studio](https://aistudio.google.com/apikey). +- Сетевой доступ к `generativelanguage.googleapis.com`. ## Установка ### 1. Добавьте плагин в `~/.config/opencode/opencode.json` -В секцию `plugin` добавьте следующую запись: +В секцию `plugin` добавьте запись (tuple-форма с опциями): ```json { "plugin": [ // ... другие плагины - "git+https://git.totmin.ru/en2zmax/vision-bridge.git" + [ + "git+https://git.totmin.ru/en2zmax/vision-bridge.git", + { + "baseURL": "https://generativelanguage.googleapis.com/v1beta/openai", + "apiKey": "{env:GEMINI_API_KEY}", + "model": "gemini-2.5-flash" + } + ] ] } ``` -### 2. Настройте провайдер Ludmila AI +`apiKey` можно указать явной строкой, шаблоном `{env:ИМЯ_ПЕРЕМЕННОЙ}` — либо не указывать вовсе, тогда плагин возьмёт его из `GEMINI_API_KEY`. -В секцию `provider` вашего `~/.config/opencode/opencode.json` добавьте или обновите блок `ludmila-ai`: +### 2. Задайте API-ключ + +```bash +export GEMINI_API_KEY=ВАШ_КЛЮЧ +``` + +Добавьте это в `~/.bashrc` / `~/.zshrc`, чтобы ключ был доступен при каждом запуске. + +### 3. (Опционально) Зарегистрируйте провайдера Google + +Нужно только если вы хотите использовать модель `gemini-2.5-flash` как основную или для vision-сабагента: ```json { "provider": { - "ludmila-ai": { - "npm": "@ai-sdk/openai-compatible", - "name": "Ludmila AI", + "google": { + "npm": "@ai-sdk/google", + "name": "Google", "options": { - "baseURL": "https://ai.totmin.ru/v1", - "apiKey": "<ЗАМЕНИТЕ_НА_ВАШ_API_КЛЮЧ>" + "apiKey": "{env:GEMINI_API_KEY}" }, "models": { - "gemini/gemini-2.5-flash": { - "attachment": true, - "limit": { - "context": 1048576, - "output": 65536 - } - } + "gemini-2.5-flash": {} } } } } ``` -**ВАЖНО**: Замените `<ЗАМЕНИТЕ_НА_ВАШ_API_КЛЮЧ>` на ваш реальный API-ключ Ludmila AI. -### 3. Скопируйте файл субагента +### 4. Скопируйте файл субагента Субагенты не загружаются из npm-пакетов, поэтому скопируйте файл `vision.md` в соответствующий каталог: ```bash -cp ~/totmin/opencode/plugins/vision-bridge/agents/vision.md ~/.config/opencode/agents/vision.md +cp agents/vision.md ~/.config/opencode/agents/vision.md ``` -### 4. Перезапустите Opencode +Субагент использует модель `google/gemini-2.5-flash`. + +### 5. Перезапустите Opencode Изменения в конфигурации применяются только после перезапуска Opencode. +## Подключение другого OpenAI-совместимого сервиса + +Плагин универсален: подойдёт любой сервис с эндпоинтом `/chat/completions`. Достаточно поменять опции: + +- **OpenAI**: `baseURL: "https://api.openai.com/v1"`, `model: "gpt-4o-mini"`, `apiKey: "{env:OPENAI_API_KEY}"`. +- **Любой OpenAI-совместимый прокси** — аналогично. + +## Опции плагина + +| Опция | По умолчанию | Назначение | +| --- | --- | --- | +| `baseURL` | `https://generativelanguage.googleapis.com/v1beta/openai` | Эндпоинт `/chat/completions` | +| `apiKey` | `process.env.GEMINI_API_KEY` | Ключ API (поддерживает `{env:VAR}`) | +| `model` | `gemini-2.5-flash` | Модель распознавания | +| `providerID` | `ludmila-ai` (legacy) | Читать настройки из блока `provider` конфига opencode | +| `visionModels` | модели с `attachment: true` | ID моделей, которые видят изображения нативно | + ## Пример конфигурации (`examples/opencode.json`) ```json @@ -70,31 +100,31 @@ cp ~/totmin/opencode/plugins/vision-bridge/agents/vision.md ~/.config/opencode/a "$schema": "https://opencode.ai/config.json", "plugin": [ "opencode-browser", - "git+ssh://git@git.totmin.ru:en2zmax/vision-bridge.git" + [ + "git+https://git.totmin.ru/en2zmax/vision-bridge.git", + { + "baseURL": "https://generativelanguage.googleapis.com/v1beta/openai", + "apiKey": "{env:GEMINI_API_KEY}", + "model": "gemini-2.5-flash" + } + ] ], "provider": { - "ludmila-ai": { - "npm": "@ai-sdk/openai-compatible", - "name": "Ludmila AI", + "google": { + "npm": "@ai-sdk/google", + "name": "Google", "options": { - "baseURL": "https://ai.totmin.ru/v1", - "apiKey": "<ЗАМЕНИТЕ_НА_ВАШ_API_КЛЮЧ>" + "apiKey": "{env:GEMINI_API_KEY}" }, "models": { - "gemini/gemini-2.5-flash": { - "attachment": true, - "limit": { - "context": 1048576, - "output": 65536 - } - } + "gemini-2.5-flash": {} } } }, "agent": { "vision": { "mode": "subagent", - "model": "ludmila-ai/gemini/gemini-2.5-flash", + "model": "google/gemini-2.5-flash", "description": "Анализ изображений и скриншотов (OCR текста или разбор UI)", "permission": { "read": "allow", @@ -106,3 +136,4 @@ cp ~/totmin/opencode/plugins/vision-bridge/agents/vision.md ~/.config/opencode/a } } } +``` diff --git a/agents/vision.md b/agents/vision.md index 6273e76..17aa960 100644 --- a/agents/vision.md +++ b/agents/vision.md @@ -1,7 +1,7 @@ --- description: Анализ изображений и скриншотов (OCR текста или разбор UI) mode: subagent -model: ludmila-ai/gemini/gemini-2.5-flash +model: google/gemini-2.5-flash temperature: 0.1 permission: read: allow diff --git a/examples/opencode.json b/examples/opencode.json index a1b1c26..75bdf59 100644 --- a/examples/opencode.json +++ b/examples/opencode.json @@ -2,31 +2,31 @@ "$schema": "https://opencode.ai/config.json", "plugin": [ "opencode-browser", - "git+https://git.totmin.ru/en2zmax/vision-bridge.git" + [ + "git+https://git.totmin.ru/en2zmax/vision-bridge.git", + { + "baseURL": "https://generativelanguage.googleapis.com/v1beta/openai", + "apiKey": "{env:GEMINI_API_KEY}", + "model": "gemini-2.5-flash" + } + ] ], "provider": { - "ludmila-ai": { - "npm": "@ai-sdk/openai-compatible", - "name": "Ludmila AI", + "google": { + "npm": "@ai-sdk/google", + "name": "Google", "options": { - "baseURL": "https://ai.totmin.ru/v1", - "apiKey": "<ЗАМЕНИТЕ_НА_ВАШ_API_КЛЮЧ>" + "apiKey": "{env:GEMINI_API_KEY}" }, "models": { - "gemini/gemini-2.5-flash": { - "attachment": true, - "limit": { - "context": 1048576, - "output": 65536 - } - } + "gemini-2.5-flash": {} } } }, "agent": { "vision": { "mode": "subagent", - "model": "ludmila-ai/gemini/gemini-2.5-flash", + "model": "google/gemini-2.5-flash", "description": "Анализ изображений и скриншотов (OCR текста или разбор UI)", "permission": { "read": "allow", diff --git a/package.json b/package.json index b4ca57a..2710ca4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vision-bridge", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "Opencode plugin: bridges images to a vision model when the active model cannot see images.", "main": "src/vision-bridge.ts", diff --git a/src/vision-bridge.ts b/src/vision-bridge.ts index 84cd1a1..138cc09 100644 --- a/src/vision-bridge.ts +++ b/src/vision-bridge.ts @@ -39,6 +39,19 @@ interface ProviderInfo { const IMAGE_MIME_RE = /^image\// +// Default recognition backend: Google Gemini (OpenAI-compatible endpoint). +// Override via plugin options: { baseURL, apiKey, model } or a config provider +// block (providerID). Legacy fallback: the "ludmila-ai" provider block. +const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" +const DEFAULT_MODEL = "gemini-2.5-flash" +const DEFAULT_PROVIDER_ID = "ludmila-ai" +const ENV_TEMPLATE_RE = /\{env:([^}]+)\}/g + +function resolveTemplates(value: string | undefined): string | undefined { + if (typeof value !== "string") return value + return value.replace(ENV_TEMPLATE_RE, (_m, name: string) => process.env[name] ?? "") +} + function isVisionModel(modelID: string | undefined, visionModels: Set): boolean { if (!modelID) return false // Normalize: strip provider prefix if present, and also handle "provider/model" form @@ -102,7 +115,7 @@ async function recognizeImage( } } -export const VisionBridge: Plugin = async () => { +export const VisionBridge: Plugin = async (_input: any, options: any = {}) => { let config: any = null let providerInfo: ProviderInfo | null = null let visionModels = new Set() @@ -114,29 +127,62 @@ export const VisionBridge: Plugin = async () => { // Track which sessions are running a vision-capable model. const visionSessions = new Map() - const providerID = "ludmila-ai" - - function loadProvider(configAny: any) { - const p = configAny?.provider?.[providerID] - const baseURL = p?.options?.baseURL - const apiKey = p?.options?.apiKey - if (!baseURL || !apiKey) { - providerInfo = null + // Resolve the recognition backend. Priority: + // 1. plugin options: { baseURL, apiKey, model } — Google Gemini defaults. + // 2. options.providerID → read that provider block from opencode config. + // 3. legacy: the "ludmila-ai" provider block (keeps existing setups working). + function resolveProvider() { + const optKey = resolveTemplates(options.apiKey) ?? process.env.GEMINI_API_KEY + if (optKey) { + providerInfo = { + baseURL: (resolveTemplates(options.baseURL) || DEFAULT_BASE_URL).replace(/\/$/, ""), + apiKey: optKey, + visionModel: resolveTemplates(options.model) || DEFAULT_MODEL, + } return } - providerInfo = { baseURL: baseURL.replace(/\/$/, ""), apiKey, visionModel: "" } - const models = p?.models ?? {} + + const pid = options.providerID || DEFAULT_PROVIDER_ID + const p = config?.provider?.[pid] + const pBase = p?.options?.baseURL + const pKey = p?.options?.apiKey + if (pBase && pKey) { + let pmodel = resolveTemplates(options.model) || "" + if (!pmodel) { + for (const [id, m] of Object.entries(p?.models ?? {})) { + if (m?.attachment === true) { + pmodel = typeof m?.id === "string" && m.id ? m.id : id + break + } + } + } + providerInfo = { + baseURL: String(pBase).replace(/\/$/, ""), + apiKey: pKey, + visionModel: pmodel || DEFAULT_MODEL, + } + return + } + + providerInfo = null + } + + // Collect model IDs that see images natively (attachment: true in config), + // so their sessions pass images through untouched. Also accepts an explicit + // list via options.visionModels. + function collectVisionModels(configAny: any) { visionModels = new Set() - let visionApiName = "" - for (const [id, m] of Object.entries(models)) { - if (m?.attachment === true) { - visionModels.add(id) - if (!visionApiName) visionApiName = typeof m?.id === "string" && m.id ? m.id : id + for (const [pid, p] of Object.entries(configAny?.provider ?? {})) { + for (const [id, m] of Object.entries(p?.models ?? {})) { + if (m?.attachment === true) { + visionModels.add(id) + visionModels.add(pid + "/" + id) + } } } - // The vision model used for recognition = the model flagged attachment:true. - // Change it in the config by moving the `attachment: true` flag. - providerInfo.visionModel = visionApiName + if (Array.isArray(options.visionModels)) { + for (const m of options.visionModels) visionModels.add(m) + } } function hashImage(dataUrl: string): string { @@ -192,7 +238,8 @@ export const VisionBridge: Plugin = async () => { const hooks: Hooks = { config: async (cfg: any) => { config = cfg - loadProvider(cfg) + collectVisionModels(cfg) + resolveProvider() }, "chat.message": async (input: any, output: any) => {