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

@@ -96,7 +96,7 @@ impl InsightGenerator {
/// first root under which the file exists. Insights may be generated
/// for any library — the generator itself doesn't know which — so we
/// probe each root rather than trust a single `base_path`.
fn resolve_full_path(&self, rel_path: &str) -> Option<std::path::PathBuf> {
pub(crate) fn resolve_full_path(&self, rel_path: &str) -> Option<std::path::PathBuf> {
use std::path::Path;
for lib in &self.libraries {
let candidate = Path::new(&lib.root_path).join(rel_path);
@@ -129,7 +129,7 @@ impl InsightGenerator {
/// Load image file, resize it, and encode as base64 for vision models
/// Resizes to max 1024px on longest edge to reduce context usage
fn load_image_as_base64(&self, file_path: &str) -> Result<String> {
pub(crate) fn load_image_as_base64(&self, file_path: &str) -> Result<String> {
use image::imageops::FilterType;
let full_path = self.resolve_full_path(file_path).ok_or_else(|| {
@@ -1411,7 +1411,7 @@ Return ONLY the summary, nothing else."#,
// ── Tool executors for agentic loop ────────────────────────────────
/// Dispatch a tool call to the appropriate executor
async fn execute_tool(
pub(crate) async fn execute_tool(
&self,
tool_name: &str,
arguments: &serde_json::Value,
@@ -2136,7 +2136,7 @@ Return ONLY the summary, nothing else."#,
// ── Agentic insight generation ──────────────────────────────────────
/// Build the list of tool definitions for the agentic loop
fn build_tool_definitions(has_vision: bool) -> Vec<Tool> {
pub(crate) fn build_tool_definitions(has_vision: bool) -> Vec<Tool> {
let mut tools = vec![
Tool::function(
"search_rag",