feat: Initial commit of vision-bridge plugin and agent
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
---
|
||||
description: Анализ изображений и скриншотов (OCR текста или разбор UI)
|
||||
mode: subagent
|
||||
model: ludmila-ai/gemini/gemini-2.5-flash
|
||||
temperature: 0.1
|
||||
permission:
|
||||
read: allow
|
||||
glob: allow
|
||||
external_directory: allow
|
||||
edit: deny
|
||||
bash: deny
|
||||
---
|
||||
Ты анализируешь изображение. Сначала определи тип контента:
|
||||
1) Преимущественно ТЕКСТ/документ → извлеки текст ДОСЛОВНО, сохрани структуру
|
||||
(заголовки, списки, колонки, код). Не пересказывай и не исправляй.
|
||||
2) ЭЛЕМЕНТЫ ИНТЕРФЕЙСА (веб/моб/десктоп) → опиши: тип экрана, компоненты и
|
||||
расположение, тексты кнопок/полей, состояния, интерактивность, стили.
|
||||
3) Смешанное → сделай оба пункта раздельно.
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Plugin, Hooks } from "@opencode-ai/plugin"
|
||||
|
||||
const VISION_PROMPT = `You are a vision assistant. Analyze the image in detail and produce a thorough text representation.
|
||||
|
||||
Rules:
|
||||
1. If the image is predominantly TEXT (code, error message, document, chat, log, terminal output) — transcribe the text VERBATIM. Preserve structure (headings, lists, columns, indentation). Do not paraphrase or "fix" typos.
|
||||
2. If the image shows a UI / INTERFACE (web, mobile, desktop) — describe: type of screen, components and their layout, exact button/label/field texts, states, interactivity, visual style.
|
||||
3. If mixed — do BOTH, in separate labeled sections.
|
||||
4. If the image is a graph/diagram/chart — describe axes, labels, values, and the overall message.
|
||||
|
||||
Be precise and exhaustive. This text will be given to another model that cannot see the image.`
|
||||
|
||||
type ImagePart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "file"
|
||||
mime: string
|
||||
filename?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
type TextPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
}
|
||||
|
||||
type AnyPart = { type: string; [key: string]: any }
|
||||
|
||||
interface ProviderInfo {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
visionModel: string
|
||||
}
|
||||
|
||||
const IMAGE_MIME_RE = /^image\//
|
||||
|
||||
function isVisionModel(modelID: string | undefined, visionModels: Set<string>): boolean {
|
||||
if (!modelID) return false
|
||||
// Normalize: strip provider prefix if present, and also handle "provider/model" form
|
||||
for (const m of visionModels) {
|
||||
if (modelID === m) return true
|
||||
if (modelID.endsWith("/" + m)) return true
|
||||
if (m.endsWith("/" + modelID)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function mimeToDataUrl(url: string, mime: string): string {
|
||||
// url is already data:image/png;base64,...
|
||||
if (url.startsWith("data:")) return url
|
||||
return `data:${mime};base64,${url}`
|
||||
}
|
||||
|
||||
async function recognizeImage(
|
||||
provider: ProviderInfo,
|
||||
modelID: string,
|
||||
dataUrl: string,
|
||||
timeoutMs = 60000,
|
||||
): Promise<string> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch(`${provider.baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelID,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: VISION_PROMPT },
|
||||
{ type: "image_url", image_url: { url: dataUrl } },
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 4096,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`Vision API ${res.status}: ${body.slice(0, 300)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.choices?.[0]?.message?.content
|
||||
if (typeof text !== "string" || !text.trim()) {
|
||||
throw new Error("Vision API returned empty content")
|
||||
}
|
||||
return text.trim()
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const VisionBridge: Plugin = async () => {
|
||||
let config: any = null
|
||||
let providerInfo: ProviderInfo | null = null
|
||||
let visionModels = new Set<string>()
|
||||
|
||||
// Image content cache keyed by image data hash to avoid re-analyzing
|
||||
// the same image on every turn.
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
// Track which sessions are running a vision-capable model.
|
||||
const visionSessions = new Map<string, boolean>()
|
||||
|
||||
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
|
||||
return
|
||||
}
|
||||
providerInfo = { baseURL: baseURL.replace(/\/$/, ""), apiKey, visionModel: "" }
|
||||
const models = p?.models ?? {}
|
||||
visionModels = new Set<string>()
|
||||
let visionApiName = ""
|
||||
for (const [id, m] of Object.entries<any>(models)) {
|
||||
if (m?.attachment === true) {
|
||||
visionModels.add(id)
|
||||
if (!visionApiName) visionApiName = typeof m?.id === "string" && m.id ? m.id : 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
|
||||
}
|
||||
|
||||
function hashImage(dataUrl: string): string {
|
||||
// Use a small fixed part of the base64 as a cheap cache key
|
||||
const i = dataUrl.indexOf(",")
|
||||
const b64 = i >= 0 ? dataUrl.slice(i + 1) : dataUrl
|
||||
let h = 0
|
||||
const step = Math.max(1, Math.floor(b64.length / 512))
|
||||
for (let k = 0; k < b64.length; k += step) {
|
||||
h = (h * 31 + b64.charCodeAt(k)) | 0
|
||||
}
|
||||
return h.toString(16) + ":" + b64.length
|
||||
}
|
||||
|
||||
async function processImagePart(
|
||||
part: ImagePart,
|
||||
modelID: string | undefined,
|
||||
isVision: boolean,
|
||||
): Promise<AnyPart> {
|
||||
if (isVision || !providerInfo) {
|
||||
// Vision model sees the image natively, or no provider config → keep original part
|
||||
return part
|
||||
}
|
||||
const base: TextPart = {
|
||||
id: part.id,
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
}
|
||||
const dataUrl = mimeToDataUrl(part.url, part.mime)
|
||||
const key = hashImage(dataUrl)
|
||||
let text = cache.get(key)
|
||||
if (!text) {
|
||||
try {
|
||||
if (!providerInfo.visionModel) {
|
||||
throw new Error('no model with "attachment": true configured for ' + providerID)
|
||||
}
|
||||
text = await recognizeImage(providerInfo, providerInfo.visionModel, dataUrl)
|
||||
cache.set(key, text)
|
||||
if (cache.size > 200) {
|
||||
const first = cache.keys().next().value
|
||||
if (first !== undefined) cache.delete(first)
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
text = `[Image analysis failed: ${msg}]`
|
||||
}
|
||||
}
|
||||
return { ...base, text: `[Image: ${text}]` }
|
||||
}
|
||||
|
||||
const hooks: Hooks = {
|
||||
config: async (cfg: any) => {
|
||||
config = cfg
|
||||
loadProvider(cfg)
|
||||
},
|
||||
|
||||
"chat.message": async (input: any, output: any) => {
|
||||
if (!config) return
|
||||
const parts: AnyPart[] = output.parts ?? []
|
||||
const modelID = input.model?.modelID
|
||||
const isVision = isVisionModel(modelID, visionModels)
|
||||
if (input.sessionID) visionSessions.set(input.sessionID, isVision)
|
||||
let changed = false
|
||||
const newParts: AnyPart[] = []
|
||||
for (const part of parts) {
|
||||
if (
|
||||
part.type === "file" &&
|
||||
typeof part.mime === "string" &&
|
||||
IMAGE_MIME_RE.test(part.mime) &&
|
||||
typeof part.url === "string"
|
||||
) {
|
||||
const result = await processImagePart(
|
||||
{
|
||||
id: part.id,
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
},
|
||||
modelID,
|
||||
isVision,
|
||||
)
|
||||
newParts.push(result)
|
||||
changed = true
|
||||
} else {
|
||||
newParts.push(part)
|
||||
}
|
||||
}
|
||||
if (changed) output.parts = newParts
|
||||
},
|
||||
|
||||
"experimental.chat.messages.transform": async (_input: any, output: any) => {
|
||||
if (!config || !providerInfo) return
|
||||
const msgs: { info: any; parts: AnyPart[] }[] = output.messages ?? []
|
||||
for (const msg of msgs) {
|
||||
if (!msg?.parts?.length) continue
|
||||
// If this message belongs to a session known to use a vision model,
|
||||
// keep its media intact (native multimodal experience).
|
||||
const sessionID = msg.info?.sessionID ?? msg.parts[0]?.sessionID
|
||||
if (sessionID && visionSessions.get(sessionID)) continue
|
||||
let changed = false
|
||||
const newParts: AnyPart[] = []
|
||||
for (const part of msg.parts) {
|
||||
if (
|
||||
part.type === "file" &&
|
||||
typeof part.mime === "string" &&
|
||||
IMAGE_MIME_RE.test(part.mime) &&
|
||||
typeof part.url === "string"
|
||||
) {
|
||||
const result = await processImagePart(
|
||||
{
|
||||
id: part.id,
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
)
|
||||
newParts.push(result)
|
||||
changed = true
|
||||
} else {
|
||||
newParts.push(part)
|
||||
}
|
||||
}
|
||||
if (changed) msg.parts = newParts
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return hooks
|
||||
}
|
||||
|
||||
export default VisionBridge
|
||||
Reference in New Issue
Block a user