Spaces:
Paused
Paused
File size: 8,394 Bytes
ee826ee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | /**
* vision-agent.js — Visión Local para Zelin
* ===========================================
* Moondream 2B — el mejor VLM pequeño para nuestro caso:
* - 2B params, ~1.5GB RAM (Q4_K_M)
* - ScreenSpot F1@0.5 = 80.4 (UI understanding)
* - Soporta: captioning, VQA, OCR, detección de objetos
* - Entrenado en UI/screenshots específicamente
* - Via node-llama-cpp (ya en el proyecto)
*
* CAPACIDADES:
* 1. Analizar screenshots del navegador (¿qué hay en la página?)
* 2. Localizar elementos UI en pantalla (para clic preciso)
* 3. Analizar imágenes del servidor Minecraft
* 4. Analizar imágenes que los usuarios envían en Discord
* 5. OCR de texto en imágenes
*
* NOTA DE RAM:
* Con los modelos de texto ya cargados (~1.55GB):
* Añadir Moondream 2B Q4 (~1.5GB) = ~3.05GB total
* Esto supera el límite. SOLUCIÓN:
* - Moondream se carga bajo demanda y se libera tras usar
* - No se mantiene en memoria permanentemente
* - O usar API de Gemini Vision (gratis, si los modelos de texto están ocupando RAM)
*/
import path from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
import { readConfig } from './utils.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const config = readConfig();
const aiCfg = config.localAI ?? {};
const MODEL_DIR = path.join(__dirname, '..', aiCfg.modelDir ?? './models');
// Modelos de visión disponibles
const VISION_MODELS = {
moondream: {
repo : 'vikhyat/moondream2-GGUF',
file : 'moondream2-Q4_K_S.gguf',
projector: 'moondream2-mmproj-f16.gguf',
ramGB : 1.5,
desc : 'Moondream 2B — UI understanding, captioning, VQA',
},
};
// Estado del modelo de visión
let _visionModel = null;
let _visionContext = null;
let _visionReady = false;
let _lastUsed = null;
// Descargar y cargar modelo de visión (bajo demanda)
export async function initVisionModel() {
if (_visionReady) return true;
try {
const { getLlama } = await import('node-llama-cpp');
const llama = await getLlama();
const modelPath = path.join(MODEL_DIR, VISION_MODELS.moondream.file);
if (!existsSync(modelPath)) {
console.log('[Vision] Descargando Moondream 2B (~1.5GB)...');
const { createModelDownloader } = await import('node-llama-cpp');
const dl = await createModelDownloader({
modelUri : `hf:${VISION_MODELS.moondream.repo}/${VISION_MODELS.moondream.file}`,
dirPath : MODEL_DIR,
onProgress: ({ downloadedSize, totalSize }) => {
if (totalSize) process.stdout.write(`\r[Vision] ${Math.round(downloadedSize/totalSize*100)}%`);
},
});
await dl.download();
console.log('\n[Vision] Descargado ✅');
}
_visionModel = await llama.loadModel({ modelPath, gpuLayers: 0 });
_visionContext = await _visionModel.createContext({ contextSize: 2048 });
_visionReady = true;
console.log('[Vision] ✅ Moondream 2B listo');
return true;
} catch (e) {
console.warn('[Vision] Modelo local no disponible:', e.message);
_visionReady = false;
return false;
}
}
// Liberar modelo de visión de la memoria (para recuperar RAM)
export async function unloadVisionModel() {
if (_visionModel) {
try { await _visionModel.dispose?.(); } catch {}
_visionModel = null;
_visionContext = null;
_visionReady = false;
console.log('[Vision] Modelo liberado de memoria');
}
}
// ── Análisis de imagen con Gemini Vision (fallback si no hay modelo local) ───
async function analyzeWithGeminiVision(base64Image, prompt) {
const geminiKeys = config.ai?.gemini?.keys ?? [config.ai?.gemini?.apiKey].filter(Boolean);
const key = geminiKeys[0];
if (!key) throw new Error('Sin Gemini key para visión');
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`,
{
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
contents: [{
parts: [
{ text: prompt },
{ inline_data: { mime_type: 'image/jpeg', data: base64Image } },
],
}],
generationConfig: { maxOutputTokens: 500, temperature: 0.3 },
}),
signal: AbortSignal.timeout(20000),
}
);
if (!res.ok) throw new Error('Gemini Vision HTTP ' + res.status);
const j = await res.json();
return j.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
}
// ── Función principal de análisis de imagen ───────────────────────────────────
export async function analyzeImage(base64Image, question = '', options = {}) {
const {
task = 'describe', // 'describe' | 'ocr' | 'ui' | 'qa'
} = options;
_lastUsed = Date.now();
// Construir prompt según la tarea
let prompt;
switch (task) {
case 'ocr':
prompt = 'Transcribe todo el texto visible en esta imagen en orden de lectura natural.';
break;
case 'ui':
prompt = question || 'Describe los elementos de interfaz de usuario visibles: botones, menús, formularios, textos. ¿Qué se puede hacer en esta página?';
break;
case 'qa':
prompt = question || '¿Qué muestra esta imagen?';
break;
case 'minecraft':
prompt = question || 'Describe lo que ves en esta captura de Minecraft: jugadores, bloques, estructuras, items, texto en pantalla.';
break;
default:
prompt = question || 'Describe esta imagen en detalle en español.';
}
// Intentar primero con Gemini Vision (más rápido y no consume RAM de Node.js)
// El modelo local solo si Gemini no está disponible
const useGemini = !!config.ai?.gemini?.keys?.length || !!config.ai?.gemini?.apiKey;
if (useGemini) {
try {
const result = await analyzeWithGeminiVision(base64Image, prompt);
if (result?.trim()) {
console.log('[Vision] ✅ Gemini Vision respondió');
return result;
}
} catch (e) {
console.warn('[Vision] Gemini Vision falló:', e.message);
}
}
// Fallback: modelo local (si está disponible y hay RAM)
if (await initVisionModel()) {
try {
const { LlamaChatSession } = await import('node-llama-cpp');
const session = new LlamaChatSession({
contextSequence: _visionContext.getSequence(),
});
const result = await session.prompt(prompt, {
maxTokens : 400,
images : [Buffer.from(base64Image, 'base64')],
});
session.dispose?.();
return result?.trim() ?? '';
} catch (e) {
console.warn('[Vision] Modelo local falló:', e.message);
}
}
return 'No se pudo analizar la imagen (sin modelo de visión disponible).';
}
// ── Analizar screenshot del navegador ─────────────────────────────────────────
export async function analyzeBrowserScreenshot(base64Screenshot, question = '') {
return analyzeImage(base64Screenshot, question || '¿Qué muestra esta página web? Describe el contenido principal, botones disponibles y cualquier texto importante.', { task: 'ui' });
}
// ── Analizar imagen del servidor Minecraft ────────────────────────────────────
export async function analyzeMinecraftImage(base64Image, question = '') {
return analyzeImage(base64Image, question, { task: 'minecraft' });
}
// ── Analizar imagen enviada por usuario en Discord ────────────────────────────
export async function analyzeUserImage(base64Image, question = '') {
return analyzeImage(base64Image, question, { task: 'qa' });
}
// ── Stats ─────────────────────────────────────────────────────────────────────
export function getVisionStats() {
return {
localModelReady: _visionReady,
model : VISION_MODELS.moondream.file,
ramGB : VISION_MODELS.moondream.ramGB,
lastUsed : _lastUsed,
geminiAvailable: !!(config.ai?.gemini?.keys?.length || config.ai?.gemini?.apiKey),
};
}
|