diff options
Diffstat (limited to 'crates/asist-llm/src')
| -rw-r--r-- | crates/asist-llm/src/client.rs | 88 |
1 files changed, 88 insertions, 0 deletions
diff --git a/crates/asist-llm/src/client.rs b/crates/asist-llm/src/client.rs index 1dca2af..7282947 100644 --- a/crates/asist-llm/src/client.rs +++ b/crates/asist-llm/src/client.rs @@ -93,6 +93,94 @@ impl LlmClient { } } + /// Pregunta al modelo por una imagen, en una petición aparte de la + /// conversación. + /// + /// El servidor tiene cargado el proyector multimodal (`--mmproj`), así que + /// acepta partes `image_url` con la imagen en base64. Se hace fuera del + /// historial a propósito: una imagen ocupa cientos de tokens de contexto y + /// arrastrarla turno tras turno saldría carísimo para lo poco que aporta + /// una vez descrita. Lo que vuelve a la conversación es el texto. + /// + /// Medido en esta máquina, el coste depende mucho del tamaño: 1,3 s a + /// 320x240, 2,9 s a 640x480 y 7,8 s a 1280x720. + pub fn look(&self, image_jpeg: &[u8], question: &str, cancel: &Cancel) -> Result<String> { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(image_jpeg); + + let mut body = json!({ + "messages": [{ + "role": "user", + "content": [ + { "type": "text", "text": question }, + { + "type": "image_url", + "image_url": { "url": format!("data:image/jpeg;base64,{encoded}") } + } + ] + }], + "stream": true, + "temperature": self.config.temperature, + "max_tokens": self.config.max_tokens, + }); + if !self.config.model.is_empty() { + body["model"] = json!(self.config.model); + } + + // En streaming aunque no se use el texto parcial: una petición de + // visión tarda segundos y sin flujo el socket puede quedarse callado + // hasta pasado el plazo de lectura. + let started = Instant::now(); + let mut text = String::new(); + let mut finished = false; + let result = self + .http + .post_json_lines("/v1/chat/completions", &body, cancel, |line| { + let Some(payload) = line.strip_prefix("data: ") else { + return true; + }; + if payload.trim() == "[DONE]" { + finished = true; + return false; + } + let Ok(event) = serde_json::from_str::<Value>(payload) else { + return true; + }; + let Some(choice) = event.get("choices").and_then(|c| c.get(0)) else { + return true; + }; + if let Some(chunk) = choice + .get("delta") + .and_then(|d| d.get("content")) + .and_then(Value::as_str) + { + text.push_str(chunk); + } + if choice.get("finish_reason").is_some_and(|r| !r.is_null()) { + finished = true; + return false; + } + true + }); + match result { + Ok(()) => {} + Err(Error::Cancelled) if finished || cancel.is_cancelled() => {} + Err(err) => return Err(err), + } + + tracing::debug!( + target: "llm", + bytes = image_jpeg.len(), + ms = started.elapsed().as_millis(), + "visión" + ); + let text = text.trim().to_string(); + if text.is_empty() { + return Err(Error::Llm("el modelo no describió la imagen".into())); + } + Ok(text) + } + fn request_body(&self, chat: &Conversation, tools: Option<&ToolRegistry>) -> Value { let mut body = json!({ "messages": chat.to_json(), |