feat(ai): chat continuation for photo insights (server v1)

Adds POST /insights/chat and GET /insights/chat/history. Replays the
stored agentic conversation through the same backend the insight was
generated with (or a per-turn override), runs a short tool-calling
loop, and persists the extended history in append or amend mode.

Backend switching: same-backend or hybrid->local replay verbatim;
local->hybrid is rejected in v1 (would require on-the-fly vision
description rewrite).

Per-(library, file) async mutex serialises concurrent turns. Soft
context budget drops oldest tool_call+result pairs when the
serialized history exceeds num_ctx - 2048 tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-21 13:00:27 -04:00
parent e2eefbd156
commit 0b9528f61e
7 changed files with 907 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
use crate::ai::openrouter::OpenRouterClient;
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
use crate::database::{
@@ -44,6 +45,8 @@ pub struct AppState {
pub openrouter_allowed_models: Vec<String>,
pub sms_client: SmsApiClient,
pub insight_generator: InsightGenerator,
/// Chat continuation service. Hold an Arc so handlers can clone cheaply.
pub insight_chat: Arc<InsightChatService>,
}
impl AppState {
@@ -76,6 +79,7 @@ impl AppState {
openrouter_allowed_models: Vec<String>,
sms_client: SmsApiClient,
insight_generator: InsightGenerator,
insight_chat: Arc<InsightChatService>,
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
) -> Self {
assert!(
@@ -109,6 +113,7 @@ impl AppState {
openrouter_allowed_models,
sms_client,
insight_generator,
insight_chat,
}
}
@@ -199,6 +204,18 @@ impl Default for AppState {
libraries_vec.clone(),
);
// Chat continuation reuses the generator for tool dispatch + image
// loading. The lock map starts empty and grows lazily per file.
let chat_locks: ChatLockMap =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
let insight_chat = Arc::new(InsightChatService::new(
Arc::new(insight_generator.clone()),
ollama.clone(),
openrouter.clone(),
insight_dao.clone(),
chat_locks,
));
// Ensure preview clips directory exists
let preview_clips_path =
env::var("PREVIEW_CLIPS_DIRECTORY").unwrap_or_else(|_| "preview_clips".to_string());
@@ -218,6 +235,7 @@ impl Default for AppState {
openrouter_allowed_models,
sms_client,
insight_generator,
insight_chat,
preview_dao,
)
}
@@ -320,6 +338,16 @@ impl AppState {
vec![test_lib],
);
let chat_locks: ChatLockMap =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
let insight_chat = Arc::new(InsightChatService::new(
Arc::new(insight_generator.clone()),
ollama.clone(),
None,
insight_dao.clone(),
chat_locks,
));
// Initialize test preview DAO
let preview_dao: Arc<Mutex<Box<dyn PreviewDao>>> =
Arc::new(Mutex::new(Box::new(SqlitePreviewDao::new())));
@@ -343,6 +371,7 @@ impl AppState {
Vec::new(),
sms_client,
insight_generator,
insight_chat,
preview_dao,
)
}