Adds a `backend` column to photo_insights (default 'local', migration 2026-04-20-000000) and a corresponding optional `backend` field on the agentic request. When a request sets backend=hybrid: - The local Ollama vision model is called once via describe_image to produce a text description. - The description is inlined into the first user message as text — no base64 image is ever sent to the chat model. - The agentic tool-calling loop and title generation route through an OpenRouterClient (dispatched via &dyn LlmClient), letting the user pick any tool-capable model from OpenRouter per request. - describe_photo is removed from the offered tools since the description is already present. Embeddings and vision stay on local Ollama regardless of backend. Hybrid mode requires OPENROUTER_API_KEY; handlers return a clear error when hybrid is requested without it, and also when the selected OpenRouter model lacks tool-calling support. AppState gains an optional openrouter client built from OPENROUTER_API_KEY / OPENROUTER_BASE_URL / OPENROUTER_DEFAULT_MODEL / OPENROUTER_EMBEDDING_MODEL / attribution headers. Default model is anthropic/claude-sonnet-4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
242 lines
6.2 KiB
Rust
242 lines
6.2 KiB
Rust
use crate::database::schema::{
|
|
entities, entity_facts, entity_photo_links, favorites, image_exif, libraries, photo_insights,
|
|
users, video_preview_clips,
|
|
};
|
|
use serde::Serialize;
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = users)]
|
|
pub struct InsertUser<'a> {
|
|
pub username: &'a str,
|
|
pub password: &'a str,
|
|
}
|
|
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct User {
|
|
pub id: i32,
|
|
pub username: String,
|
|
#[serde(skip_serializing)]
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = favorites)]
|
|
pub struct InsertFavorite<'a> {
|
|
pub userid: &'a i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub path: &'a str,
|
|
}
|
|
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct Favorite {
|
|
pub id: i32,
|
|
pub userid: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub path: String,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = image_exif)]
|
|
pub struct InsertImageExif {
|
|
pub library_id: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub file_path: String,
|
|
pub camera_make: Option<String>,
|
|
pub camera_model: Option<String>,
|
|
pub lens_model: Option<String>,
|
|
pub width: Option<i32>,
|
|
pub height: Option<i32>,
|
|
pub orientation: Option<i32>,
|
|
pub gps_latitude: Option<f32>,
|
|
pub gps_longitude: Option<f32>,
|
|
pub gps_altitude: Option<f32>,
|
|
pub focal_length: Option<f32>,
|
|
pub aperture: Option<f32>,
|
|
pub shutter_speed: Option<String>,
|
|
pub iso: Option<i32>,
|
|
pub date_taken: Option<i64>,
|
|
pub created_time: i64,
|
|
pub last_modified: i64,
|
|
pub content_hash: Option<String>,
|
|
pub size_bytes: Option<i64>,
|
|
}
|
|
|
|
// Field order matches the post-migration column order in `image_exif`.
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct ImageExif {
|
|
pub id: i32,
|
|
pub library_id: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub file_path: String,
|
|
pub camera_make: Option<String>,
|
|
pub camera_model: Option<String>,
|
|
pub lens_model: Option<String>,
|
|
pub width: Option<i32>,
|
|
pub height: Option<i32>,
|
|
pub orientation: Option<i32>,
|
|
pub gps_latitude: Option<f32>,
|
|
pub gps_longitude: Option<f32>,
|
|
pub gps_altitude: Option<f32>,
|
|
pub focal_length: Option<f32>,
|
|
pub aperture: Option<f32>,
|
|
pub shutter_speed: Option<String>,
|
|
pub iso: Option<i32>,
|
|
pub date_taken: Option<i64>,
|
|
pub created_time: i64,
|
|
pub last_modified: i64,
|
|
pub content_hash: Option<String>,
|
|
pub size_bytes: Option<i64>,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = photo_insights)]
|
|
pub struct InsertPhotoInsight {
|
|
pub library_id: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub file_path: String,
|
|
pub title: String,
|
|
pub summary: String,
|
|
pub generated_at: i64,
|
|
pub model_version: String,
|
|
pub is_current: bool,
|
|
pub training_messages: Option<String>,
|
|
/// `"local"` (Ollama with images) | `"hybrid"` (local vision + OpenRouter chat).
|
|
pub backend: String,
|
|
}
|
|
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct PhotoInsight {
|
|
pub id: i32,
|
|
pub library_id: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub file_path: String,
|
|
pub title: String,
|
|
pub summary: String,
|
|
pub generated_at: i64,
|
|
pub model_version: String,
|
|
pub is_current: bool,
|
|
pub training_messages: Option<String>,
|
|
pub approved: Option<bool>,
|
|
/// `"local"` (Ollama with images) | `"hybrid"` (local vision + OpenRouter chat).
|
|
pub backend: String,
|
|
}
|
|
|
|
// --- Libraries ---
|
|
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct LibraryRow {
|
|
pub id: i32,
|
|
pub name: String,
|
|
pub root_path: String,
|
|
pub created_at: i64,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = libraries)]
|
|
pub struct InsertLibrary<'a> {
|
|
pub name: &'a str,
|
|
pub root_path: &'a str,
|
|
pub created_at: i64,
|
|
}
|
|
|
|
// --- Knowledge memory models ---
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = entities)]
|
|
pub struct InsertEntity {
|
|
pub name: String,
|
|
pub entity_type: String,
|
|
pub description: String,
|
|
pub embedding: Option<Vec<u8>>,
|
|
pub confidence: f32,
|
|
pub status: String,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
}
|
|
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct Entity {
|
|
pub id: i32,
|
|
pub name: String,
|
|
pub entity_type: String,
|
|
pub description: String,
|
|
pub embedding: Option<Vec<u8>>,
|
|
pub confidence: f32,
|
|
pub status: String,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = entity_facts)]
|
|
pub struct InsertEntityFact {
|
|
pub subject_entity_id: i32,
|
|
pub predicate: String,
|
|
pub object_entity_id: Option<i32>,
|
|
pub object_value: Option<String>,
|
|
pub source_photo: Option<String>,
|
|
pub source_insight_id: Option<i32>,
|
|
pub confidence: f32,
|
|
pub status: String,
|
|
pub created_at: i64,
|
|
}
|
|
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct EntityFact {
|
|
pub id: i32,
|
|
pub subject_entity_id: i32,
|
|
pub predicate: String,
|
|
pub object_entity_id: Option<i32>,
|
|
pub object_value: Option<String>,
|
|
pub source_photo: Option<String>,
|
|
pub source_insight_id: Option<i32>,
|
|
pub confidence: f32,
|
|
pub status: String,
|
|
pub created_at: i64,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = entity_photo_links)]
|
|
pub struct InsertEntityPhotoLink {
|
|
pub entity_id: i32,
|
|
pub library_id: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub file_path: String,
|
|
pub role: String,
|
|
}
|
|
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct EntityPhotoLink {
|
|
pub id: i32,
|
|
pub entity_id: i32,
|
|
pub library_id: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub file_path: String,
|
|
pub role: String,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = video_preview_clips)]
|
|
pub struct InsertVideoPreviewClip {
|
|
pub library_id: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub file_path: String,
|
|
pub status: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
|
pub struct VideoPreviewClip {
|
|
pub id: i32,
|
|
pub library_id: i32,
|
|
#[diesel(column_name = rel_path)]
|
|
pub file_path: String,
|
|
pub status: String,
|
|
pub duration_seconds: Option<f32>,
|
|
pub file_size_bytes: Option<i32>,
|
|
pub error_message: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|