Frontend:
- ChatInput: paste-handler detekterer bilder fra clipboard (ClipboardEvent),
laster opp til CAS via uploadMedia med metadata_extra { source: "screenshot" }
- Chat-side: viser bildenoder inline med AI-beskrivelse når tilgjengelig
- api.ts: uploadMedia støtter nå metadata_extra for ekstra node-metadata
Backend (maskinrommet):
- upload_media: nytt metadata_extra multipart-felt som merges inn i
media-nodens metadata (f.eks. source, description)
- describe_image: ny jobbtype — enqueuues automatisk for screenshot-uploads,
kaller synops-ai med --image for AI-beskrivelse av bildet
- Beskrivelsen lagres tilbake i media-nodens metadata.description
synops-ai:
- Nytt --image flag for multimodal LLM-kall (vision) via LiteLLM
- Sender bilde som base64 data-URL i OpenAI-kompatibelt format
- Brukes av describe_image-jobben for bildbeskrivelse
108 lines
3.2 KiB
Rust
108 lines
3.2 KiB
Rust
// describe_image — AI-beskrivelse av bilder via synops-ai CLI.
|
|
//
|
|
// Jobbtype: "describe_image"
|
|
// Payload: { "media_node_id", "cas_hash", "mime" }
|
|
//
|
|
// Flyten:
|
|
// 1. Sender bildefilen (CAS-sti) til synops-ai prompt --image
|
|
// 2. synops-ai sender multimodal melding til LLM via LiteLLM
|
|
// 3. Oppdaterer media-nodens metadata med description-feltet
|
|
//
|
|
// Ref: docs/retninger/unix_filosofi.md, oppgave 29.1
|
|
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use crate::cas::CasStore;
|
|
use crate::jobs::JobRow;
|
|
|
|
/// Håndterer describe_image-jobb.
|
|
///
|
|
/// Sender bildet til synops-ai for beskrivelse via vision-modell,
|
|
/// og lagrer resultatet i media-nodens metadata.
|
|
pub async fn handle_describe_image(
|
|
job: &JobRow,
|
|
db: &PgPool,
|
|
cas: &CasStore,
|
|
) -> Result<serde_json::Value, String> {
|
|
let media_node_id: Uuid = job.payload["media_node_id"]
|
|
.as_str()
|
|
.and_then(|s| s.parse().ok())
|
|
.ok_or("Mangler gyldig 'media_node_id' i payload")?;
|
|
|
|
let cas_hash = job.payload["cas_hash"]
|
|
.as_str()
|
|
.ok_or("Mangler 'cas_hash' i payload")?;
|
|
|
|
let mime = job.payload["mime"]
|
|
.as_str()
|
|
.unwrap_or("image/png");
|
|
|
|
// Verifiser at bildet finnes i CAS
|
|
let cas_path = cas.path_for(cas_hash);
|
|
if !cas_path.exists() {
|
|
return Err(format!("Bilde ikke funnet i CAS: {cas_hash}"));
|
|
}
|
|
|
|
let bin = std::env::var("SYNOPS_AI_BIN")
|
|
.unwrap_or_else(|_| "synops-ai".to_string());
|
|
|
|
tracing::info!(
|
|
media_node_id = %media_node_id,
|
|
cas_hash,
|
|
"Sender bilde til AI for beskrivelse"
|
|
);
|
|
|
|
let output = tokio::process::Command::new(&bin)
|
|
.arg("prompt")
|
|
.arg("--prompt")
|
|
.arg("Beskriv dette bildet kort og konsist på norsk. Fokuser på innholdet, ikke formatet. Maks 2-3 setninger.")
|
|
.arg("--image")
|
|
.arg(cas_path.to_str().unwrap_or(""))
|
|
.arg("--image-mime")
|
|
.arg(mime)
|
|
.arg("--job-type")
|
|
.arg("describe_image")
|
|
.arg("--temperature")
|
|
.arg("0.3")
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Kunne ikke starte {bin}: {e}"))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("synops-ai feilet: {stderr}"));
|
|
}
|
|
|
|
let description = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
|
|
if description.is_empty() {
|
|
return Err("synops-ai returnerte tom beskrivelse".into());
|
|
}
|
|
|
|
tracing::info!(
|
|
media_node_id = %media_node_id,
|
|
description_len = description.len(),
|
|
"Bildebeskrivelse mottatt fra AI"
|
|
);
|
|
|
|
// Oppdater media-nodens metadata med description
|
|
sqlx::query(
|
|
r#"UPDATE nodes
|
|
SET metadata = metadata || jsonb_build_object('description', $2::text),
|
|
updated_at = now()
|
|
WHERE id = $1"#,
|
|
)
|
|
.bind(media_node_id)
|
|
.bind(&description)
|
|
.execute(db)
|
|
.await
|
|
.map_err(|e| format!("Kunne ikke oppdatere media-node med beskrivelse: {e}"))?;
|
|
|
|
// PG NOTIFY-trigger på nodes-tabellen sender sanntidsoppdatering automatisk.
|
|
|
|
Ok(serde_json::json!({
|
|
"media_node_id": media_node_id.to_string(),
|
|
"description": description,
|
|
}))
|
|
}
|