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),
  };
}