aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-llm/src/client.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-llm/src/client.rs')
-rw-r--r--crates/asist-llm/src/client.rs88
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(),