Merge pull request 'Feature/open chat with persona' (#110) from feature/open-chat-with-persona into master

Reviewed-on: #110
This commit was merged in pull request #110.
This commit is contained in:
2026-09-08 21:55:37 +00:00
17 changed files with 3226 additions and 201 deletions
Generated
+1 -1
View File
@@ -2051,7 +2051,7 @@ dependencies = [
[[package]] [[package]]
name = "image-api" name = "image-api"
version = "1.4.0" version = "1.5.0"
dependencies = [ dependencies = [
"actix", "actix",
"actix-cors", "actix-cors",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "image-api" name = "image-api"
version = "1.4.0" version = "1.5.0"
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"] authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
edition = "2024" edition = "2024"
@@ -0,0 +1 @@
DROP TABLE IF EXISTS persona_chat_conversations;
@@ -0,0 +1,22 @@
-- Open chat with persona — file-anchored insight chat is keyed by
-- (library_id, file_path). The open chat is keyed by (user_id, persona_id)
-- with a single rolling transcript per pair. No tree branching in v1
-- (matches the locked-in "single rolling conversation per persona" scope);
-- a flat JSON blob is enough and avoids forcing a tree shape onto a
-- surface that's intentionally linear.
--
-- `messages_json` is the same `Vec<ChatMessage>` shape the file-anchored
-- chat persists, so the SSE replay / UI rendering on the mobile client
-- can reuse the same parser without a second schema.
CREATE TABLE persona_chat_conversations (
user_id INTEGER NOT NULL,
persona_id TEXT NOT NULL,
messages_json TEXT NOT NULL DEFAULT '[]',
turn_count INTEGER NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, persona_id)
);
CREATE INDEX idx_persona_chat_updated
ON persona_chat_conversations (user_id, updated_at DESC);
@@ -0,0 +1,32 @@
-- Collapse back to one conversation per (user, persona). Where a persona has
-- several, the most recently updated one wins and the rest are dropped —
-- the v1 schema has nowhere to put them.
CREATE TABLE persona_chat_conversations_old (
user_id INTEGER NOT NULL,
persona_id TEXT NOT NULL,
messages_json TEXT NOT NULL DEFAULT '[]',
turn_count INTEGER NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, persona_id)
);
INSERT INTO persona_chat_conversations_old (
user_id, persona_id, messages_json, turn_count, updated_at
)
SELECT user_id, persona_id, messages_json, turn_count, updated_at
FROM persona_chat_conversations c
WHERE c.updated_at = (
SELECT MAX(c2.updated_at)
FROM persona_chat_conversations c2
WHERE c2.user_id = c.user_id AND c2.persona_id = c.persona_id
)
GROUP BY user_id, persona_id;
DROP INDEX IF EXISTS idx_persona_chat_updated;
DROP INDEX IF EXISTS idx_persona_chat_persona;
DROP TABLE persona_chat_conversations;
ALTER TABLE persona_chat_conversations_old RENAME TO persona_chat_conversations;
CREATE INDEX idx_persona_chat_updated
ON persona_chat_conversations (user_id, updated_at DESC);
@@ -0,0 +1,50 @@
-- Multiple conversations per persona.
--
-- v1 keyed a transcript on (user_id, persona_id), so a persona had exactly
-- one rolling conversation and there was no way to start a fresh topic
-- without discarding the old one. The key is now an opaque `conversation_id`,
-- with (user_id, persona_id) demoted to an index.
--
-- `title` is a short generated summary of the opening exchange, used as the
-- conversation's name in the list. Empty until the first turn completes; the
-- client falls back to the persona name while it is blank.
--
-- SQLite cannot redefine a primary key in place, so this is the standard
-- create-copy-drop-rename dance. Existing transcripts carry over with a
-- generated id and an empty title.
CREATE TABLE persona_chat_conversations_new (
conversation_id TEXT NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL,
persona_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
messages_json TEXT NOT NULL DEFAULT '[]',
turn_count INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
INSERT INTO persona_chat_conversations_new (
conversation_id, user_id, persona_id, title,
messages_json, turn_count, created_at, updated_at
)
SELECT
lower(hex(randomblob(16))),
user_id,
persona_id,
'',
messages_json,
turn_count,
updated_at,
updated_at
FROM persona_chat_conversations;
DROP INDEX IF EXISTS idx_persona_chat_updated;
DROP TABLE persona_chat_conversations;
ALTER TABLE persona_chat_conversations_new RENAME TO persona_chat_conversations;
CREATE INDEX idx_persona_chat_updated
ON persona_chat_conversations (user_id, updated_at DESC);
CREATE INDEX idx_persona_chat_persona
ON persona_chat_conversations (user_id, persona_id);
+55 -9
View File
@@ -1819,6 +1819,19 @@ pub async fn turn_replay_handler(
path: web::Path<String>, path: web::Path<String>,
query: web::Query<ReplayQuery>, query: web::Query<ReplayQuery>,
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
) -> HttpResponse {
turn_replay_impl(http_request, path, query, app_state).await
}
/// Core of the SSE replay, kept attribute-free so the persona-chat routes
/// can reuse it under `/persona_chat/turn/{turn_id}`. The registry is keyed
/// on `turn_id` only, and `render_turn_info_frame` already scopes the
/// identity (persona turns carry `persona_id` and no `file_path`).
pub(crate) async fn turn_replay_impl(
http_request: HttpRequest,
path: web::Path<String>,
query: web::Query<ReplayQuery>,
app_state: web::Data<AppState>,
) -> HttpResponse { ) -> HttpResponse {
use crate::ai::turn_registry::ReplayOutcome; use crate::ai::turn_registry::ReplayOutcome;
@@ -1949,15 +1962,31 @@ pub async fn turn_replay_handler(
} }
fn render_turn_info_frame(info: &crate::ai::turn_registry::TurnInfo) -> String { fn render_turn_info_frame(info: &crate::ai::turn_registry::TurnInfo) -> String {
let payload = serde_json::json!({ // Persona-scoped turns leak no `file_path` to the client — the open
"turn_id": info.turn_id, // chat is keyed on `(persona_id)` only and a stray path echo from a
"file_path": info.file_path, // quoted SMS would be a privacy regression. Insight-scoped turns
"library_id": info.library_id, // include the path as before.
"status": info.status.as_str(), let mut payload = serde_json::Map::new();
"total_events_pushed": info.total_events_pushed, payload.insert("turn_id".into(), serde_json::json!(info.turn_id));
"buffered_count": info.buffered_count, payload.insert("library_id".into(), serde_json::json!(info.library_id));
}); payload.insert("scope".into(), serde_json::json!(info.scope));
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()); payload.insert("status".into(), serde_json::json!(info.status.as_str()));
payload.insert(
"total_events_pushed".into(),
serde_json::json!(info.total_events_pushed),
);
payload.insert(
"buffered_count".into(),
serde_json::json!(info.buffered_count),
);
if info.scope == "insight" {
payload.insert("file_path".into(), serde_json::json!(info.file_path));
}
if let Some(ref pid) = info.persona_id {
payload.insert("persona_id".into(), serde_json::json!(pid));
}
let data = serde_json::to_string(&serde_json::Value::Object(payload))
.unwrap_or_else(|_| "{}".to_string());
format!("event: turn_info\ndata: {}\n\n", data) format!("event: turn_info\ndata: {}\n\n", data)
} }
@@ -1967,6 +1996,16 @@ pub async fn cancel_turn_handler(
http_request: HttpRequest, http_request: HttpRequest,
path: web::Path<String>, path: web::Path<String>,
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
) -> impl Responder {
cancel_turn_impl(http_request, path, app_state).await
}
/// Core of the turn-cancel, attribute-free so the persona-chat DELETE route
/// can reuse it under `/persona_chat/turn/{turn_id}`.
pub(crate) async fn cancel_turn_impl(
http_request: HttpRequest,
path: web::Path<String>,
app_state: web::Data<AppState>,
) -> impl Responder { ) -> impl Responder {
let turn_id = path.into_inner(); let turn_id = path.into_inner();
@@ -2017,6 +2056,13 @@ pub async fn cancel_turn_handler(
})) }))
} }
// Persona-chat handlers (`/persona_chat/*`) live in `ai::persona_chat` and
// are registered in main.rs. The persona SSE replay and cancel routes are
// thin wrappers over `turn_replay_handler` + `cancel_turn_handler`: both
// are keyed on `turn_id`, not file_path, and `TurnInfo::scope` ("persona")
// lets the shared `turn_info` frame carry the right identity (persona_id,
// no file_path).
#[cfg(test)] #[cfg(test)]
mod turn_replay_tests { mod turn_replay_tests {
use super::{cancel_turn_handler, render_indexed_frame, turn_replay_handler}; use super::{cancel_turn_handler, render_indexed_frame, turn_replay_handler};
+213 -169
View File
@@ -20,7 +20,7 @@ use crate::utils::{normalize_path, retry_with_backoff};
use futures::stream::{BoxStream, StreamExt}; use futures::stream::{BoxStream, StreamExt};
use uuid::Uuid; use uuid::Uuid;
const DEFAULT_MAX_ITERATIONS: usize = 6; pub const DEFAULT_MAX_ITERATIONS: usize = 6;
/// Assumed context window when the request doesn't specify `num_ctx`. /// Assumed context window when the request doesn't specify `num_ctx`.
/// The llama-swap chat slots serve 20k-131k contexts and real conversations /// The llama-swap chat slots serve 20k-131k contexts and real conversations
/// rarely pass ~16k tokens, so 32k keeps the truncation pass from gutting /// rarely pass ~16k tokens, so 32k keeps the truncation pass from gutting
@@ -30,11 +30,11 @@ const DEFAULT_MAX_ITERATIONS: usize = 6;
const DEFAULT_NUM_CTX: i32 = 32768; const DEFAULT_NUM_CTX: i32 = 32768;
/// Headroom reserved for the model's response, deducted from the context /// Headroom reserved for the model's response, deducted from the context
/// budget when deciding whether to truncate the replayed history. /// budget when deciding whether to truncate the replayed history.
const RESPONSE_HEADROOM_TOKENS: usize = 2048; pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048;
/// Cheap byte-to-token approximation used by the truncation pass. The real /// Cheap byte-to-token approximation used by the truncation pass. The real
/// tokenization is model-specific; this avoids carrying tiktoken just for a /// tokenization is model-specific; this avoids carrying tiktoken just for a
/// soft bound. /// soft bound.
const BYTES_PER_TOKEN: usize = 4; pub(crate) const BYTES_PER_TOKEN: usize = 4;
/// Flat token cost charged per inlined image in the truncation budget. A /// Flat token cost charged per inlined image in the truncation budget. A
/// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on /// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on
/// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of /// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of
@@ -335,6 +335,13 @@ impl InsightChatService {
if truncated { if truncated {
span.set_attribute(KeyValue::new("history_truncated", true)); span.set_attribute(KeyValue::new("history_truncated", true));
} }
// Everything in `messages` at this point is replayed history that is
// ALREADY in the tree; only what the turn appends past this mark
// becomes new nodes. Captured after the budget pass, not from
// `path.len()`: truncation drains from the middle, so `path.len()`
// over-counts and the slice below would either swallow the new user
// turn or panic on an out-of-range start index.
let history_len = messages.len();
// 7. Append the new user turn. // 7. Append the new user turn.
messages.push(ChatMessage::user(req.user_message.clone())); messages.push(ChatMessage::user(req.user_message.clone()));
@@ -461,8 +468,7 @@ impl InsightChatService {
// relying on store_insight to flip prior rows' is_current=false. // relying on store_insight to flip prior rows' is_current=false.
// Append new messages (this turn's user + assistant exchanges) as // Append new messages (this turn's user + assistant exchanges) as
// tree nodes chained from the previous active_leaf_id. // tree nodes chained from the previous active_leaf_id.
let path_len = path.len(); let new_messages = messages[history_len..].to_vec();
let new_messages = messages[path_len..].to_vec();
let mut parent_id = Some(store.active_leaf_id); let mut parent_id = Some(store.active_leaf_id);
for msg in &new_messages { for msg in &new_messages {
let new_id = store.append_node(parent_id, msg.clone()); let new_id = store.append_node(parent_id, msg.clone());
@@ -942,7 +948,6 @@ impl InsightChatService {
let path = store let path = store
.path_to_leaf(store.active_leaf_id) .path_to_leaf(store.active_leaf_id)
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?; .ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
let path_len = path.len();
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect(); let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
let stored_backend = insight.backend.clone(); let stored_backend = insight.backend.clone();
@@ -1003,6 +1008,10 @@ impl InsightChatService {
if truncated { if truncated {
let _ = entry.push_event(ChatStreamEvent::Truncated).await; let _ = entry.push_event(ChatStreamEvent::Truncated).await;
} }
// See the note in `chat_turn`: the new-node boundary must be read
// after the budget pass, because truncation drains replayed history
// out of the middle of `messages`.
let history_len = messages.len();
messages.push(ChatMessage::user(req.user_message.clone())); messages.push(ChatMessage::user(req.user_message.clone()));
@@ -1046,7 +1055,7 @@ impl InsightChatService {
} }
// Append new messages as tree nodes. // Append new messages as tree nodes.
let new_messages = messages[path_len..].to_vec(); let new_messages = messages[history_len..].to_vec();
let mut parent_id = Some(store.active_leaf_id); let mut parent_id = Some(store.active_leaf_id);
for msg in &new_messages { for msg in &new_messages {
let new_id = store.append_node(parent_id, msg.clone()); let new_id = store.append_node(parent_id, msg.clone());
@@ -1303,7 +1312,12 @@ impl InsightChatService {
} }
/// Agentic loop variant that pushes events to a `TurnEntry` buffer. /// Agentic loop variant that pushes events to a `TurnEntry` buffer.
async fn run_streaming_agentic_loop_with_entry( /// Same as `run_streaming_agentic_loop` but emits events to a
/// `TurnEntry` for SSE replay. Thin wrapper around the free function
/// `run_streaming_agentic_loop_with_entry` so the persona chat
/// (and any future chat-without-file surface) can reuse the loop
/// body without instantiating an `InsightChatService`.
pub async fn run_streaming_agentic_loop_with_entry(
&self, &self,
backend: &ResolvedBackend, backend: &ResolvedBackend,
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
@@ -1315,160 +1329,19 @@ impl InsightChatService {
max_iterations: usize, max_iterations: usize,
entry: &Arc<TurnEntry>, entry: &Arc<TurnEntry>,
) -> Result<AgenticLoopOutcome> { ) -> Result<AgenticLoopOutcome> {
let mut tool_calls_made = 0usize; crate::ai::insight_chat::run_streaming_agentic_loop_with_entry(
let mut iterations_used = 0usize; &self.generator,
let mut last_prompt_eval_count: Option<i32> = None;
let mut last_eval_count: Option<i32> = None;
let mut final_content = String::new();
for iteration in 0..max_iterations {
// Cooperative cancellation: a DELETE flips status out of Running
// (and aborts this task). Check at the iteration boundary so an
// in-flight tool round finishes cleanly rather than mid-write.
if !entry.is_running() {
return Ok(AgenticLoopOutcome {
tool_calls_made,
iterations_used,
last_prompt_eval_count,
last_eval_count,
final_content,
cancelled: true,
});
}
iterations_used = iteration + 1;
let _ = entry
.push_event(ChatStreamEvent::IterationStart {
n: iterations_used,
max: max_iterations,
})
.await;
let mut stream = backend
.chat()
.chat_with_tools_stream(messages.clone(), tools.clone())
.await?;
let mut final_message: Option<ChatMessage> = None;
while let Some(ev) = stream.next().await {
let ev = ev?;
match ev {
LlmStreamEvent::TextDelta(delta) => {
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
}
LlmStreamEvent::Done {
message,
prompt_eval_count,
eval_count,
} => {
last_prompt_eval_count = prompt_eval_count;
last_eval_count = eval_count;
final_message = Some(message);
break;
}
}
}
let mut response =
final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?;
if let Some(ref mut tcs) = response.tool_calls {
for tc in tcs.iter_mut() {
if !tc.function.arguments.is_object() {
tc.function.arguments = serde_json::Value::Object(Default::default());
}
}
}
messages.push(response.clone());
if let Some(ref tool_calls) = response.tool_calls
&& !tool_calls.is_empty()
{
for tool_call in tool_calls {
tool_calls_made += 1;
let call_index = tool_calls_made - 1;
let _ = entry
.push_event(ChatStreamEvent::ToolCall {
index: call_index,
name: tool_call.function.name.clone(),
arguments: tool_call.function.arguments.clone(),
})
.await;
let cx = opentelemetry::Context::new();
let result = self
.generator
.execute_tool(
&tool_call.function.name,
&tool_call.function.arguments,
backend, backend,
messages,
tools,
image_base64, image_base64,
normalized, normalized,
user_id, user_id,
active_persona, active_persona,
&cx, max_iterations,
entry,
) )
.await; .await
let (result_preview, result_truncated) = truncate_tool_result(&result);
let _ = entry
.push_event(ChatStreamEvent::ToolResult {
index: call_index,
name: tool_call.function.name.clone(),
result: result_preview,
result_truncated,
})
.await;
messages.push(ChatMessage::tool_result(result));
}
continue;
}
final_content = response.content;
break;
}
// No-tools fallback
if final_content.is_empty() {
let synthetic_idx = push_synthetic_final_prompt(messages);
let mut stream = backend
.chat()
.chat_with_tools_stream(messages.clone(), vec![])
.await?;
let mut final_message: Option<ChatMessage> = None;
while let Some(ev) = stream.next().await {
let ev = ev?;
match ev {
LlmStreamEvent::TextDelta(delta) => {
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
}
LlmStreamEvent::Done {
message,
prompt_eval_count,
eval_count,
} => {
last_prompt_eval_count = prompt_eval_count;
last_eval_count = eval_count;
final_message = Some(message);
break;
}
}
}
let final_response =
final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?;
final_content = final_response.content.clone();
messages.push(final_response);
remove_synthetic_final_prompt(messages, synthetic_idx);
}
Ok(AgenticLoopOutcome {
tool_calls_made,
iterations_used,
last_prompt_eval_count,
last_eval_count,
// Strip any leaked <think> reasoning block from the content the
// caller persists as title/summary (the raw transcript keeps it).
final_content: crate::ai::llm_client::strip_think_blocks(&final_content),
cancelled: false,
})
} }
async fn run_streaming_turn( async fn run_streaming_turn(
@@ -2212,17 +2085,20 @@ fn resolve_bootstrap_backend(supplied: Option<&str>) -> Result<String> {
} }
/// Outcome of one streaming agentic loop pass. Shared between bootstrap /// Outcome of one streaming agentic loop pass. Shared between bootstrap
/// and continuation. /// and continuation. `pub` so the persona chat surface (and any future
struct AgenticLoopOutcome { /// chat-without-file surface) can read the result of
tool_calls_made: usize, /// `run_streaming_agentic_loop_with_entry` without reaching back into
iterations_used: usize, /// private fields.
last_prompt_eval_count: Option<i32>, pub struct AgenticLoopOutcome {
last_eval_count: Option<i32>, pub tool_calls_made: usize,
final_content: String, pub iterations_used: usize,
pub last_prompt_eval_count: Option<i32>,
pub last_eval_count: Option<i32>,
pub final_content: String,
/// True when the loop exited early because the turn was cancelled /// True when the loop exited early because the turn was cancelled
/// (status flipped out of `Running`). Callers skip persistence and the /// (status flipped out of `Running`). Callers skip persistence and the
/// terminal `Done` push — the cancel handler owns the terminal event. /// terminal `Done` push — the cancel handler owns the terminal event.
cancelled: bool, pub cancelled: bool,
} }
/// Events emitted by `chat_turn_stream`. One stream per turn; ends after /// Events emitted by `chat_turn_stream`. One stream per turn; ends after
@@ -2341,7 +2217,7 @@ pub(crate) fn find_raw_cut(
/// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code /// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code
/// free of static globals and lets the operator change the cap by env without /// free of static globals and lets the operator change the cap by env without
/// a restart in test harnesses (the running server still caches via Default). /// a restart in test harnesses (the running server still caches via Default).
fn env_max_iterations() -> usize { pub fn env_max_iterations() -> usize {
std::env::var("AGENTIC_CHAT_MAX_ITERATIONS") std::env::var("AGENTIC_CHAT_MAX_ITERATIONS")
.ok() .ok()
.and_then(|s| s.parse::<usize>().ok()) .and_then(|s| s.parse::<usize>().ok())
@@ -2352,7 +2228,7 @@ fn env_max_iterations() -> usize {
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context /// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context
/// window for the truncation budget when the request omits `num_ctx`. Same /// window for the truncation budget when the request omits `num_ctx`. Same
/// no-static-global rationale as `env_max_iterations` above. /// no-static-global rationale as `env_max_iterations` above.
fn env_default_num_ctx() -> i32 { pub(crate) fn env_default_num_ctx() -> i32 {
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX") std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX")
.ok() .ok()
.and_then(|s| s.parse::<i32>().ok()) .and_then(|s| s.parse::<i32>().ok())
@@ -2395,6 +2271,174 @@ fn restore_system_content(messages: &mut [ChatMessage], original: Option<String>
} }
/// Append the synthetic "write your final answer" user prompt, returning the /// Append the synthetic "write your final answer" user prompt, returning the
/// Free-function form of `InsightChatService::run_streaming_agentic_loop_with_entry`.
/// Persona chat (and any future chat-without-file surface) doesn't need an
/// `InsightChatService` — it just needs the agent loop. This is the same
/// body lifted out of the `InsightChatService` impl, taking
/// `&InsightGenerator` so any caller in the crate can drive it.
///
/// Public so the persona chat session can call into it directly.
pub async fn run_streaming_agentic_loop_with_entry(
generator: &crate::ai::insight_generator::InsightGenerator,
backend: &ResolvedBackend,
messages: &mut Vec<ChatMessage>,
tools: Vec<Tool>,
image_base64: &Option<String>,
normalized: &str,
user_id: i32,
active_persona: &str,
max_iterations: usize,
entry: &Arc<TurnEntry>,
) -> Result<AgenticLoopOutcome> {
let mut tool_calls_made = 0usize;
let mut iterations_used = 0usize;
let mut last_prompt_eval_count: Option<i32> = None;
let mut last_eval_count: Option<i32> = None;
let mut final_content = String::new();
for iteration in 0..max_iterations {
if !entry.is_running() {
return Ok(AgenticLoopOutcome {
tool_calls_made,
iterations_used,
last_prompt_eval_count,
last_eval_count,
final_content,
cancelled: true,
});
}
iterations_used = iteration + 1;
let _ = entry
.push_event(ChatStreamEvent::IterationStart {
n: iterations_used,
max: max_iterations,
})
.await;
let mut stream = backend
.chat()
.chat_with_tools_stream(messages.clone(), tools.clone())
.await?;
let mut final_message: Option<ChatMessage> = None;
while let Some(ev) = stream.next().await {
let ev = ev?;
match ev {
LlmStreamEvent::TextDelta(delta) => {
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
}
LlmStreamEvent::Done {
message,
prompt_eval_count,
eval_count,
} => {
last_prompt_eval_count = prompt_eval_count;
last_eval_count = eval_count;
final_message = Some(message);
break;
}
}
}
let mut response =
final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?;
if let Some(ref mut tcs) = response.tool_calls {
for tc in tcs.iter_mut() {
if !tc.function.arguments.is_object() {
tc.function.arguments = serde_json::Value::Object(Default::default());
}
}
}
messages.push(response.clone());
if let Some(ref tool_calls) = response.tool_calls
&& !tool_calls.is_empty()
{
for tool_call in tool_calls {
tool_calls_made += 1;
let call_index = tool_calls_made - 1;
let _ = entry
.push_event(ChatStreamEvent::ToolCall {
index: call_index,
name: tool_call.function.name.clone(),
arguments: tool_call.function.arguments.clone(),
})
.await;
let cx = opentelemetry::Context::new();
let result = generator
.execute_tool(
&tool_call.function.name,
&tool_call.function.arguments,
backend,
image_base64,
normalized,
user_id,
active_persona,
&cx,
)
.await;
let (result_preview, result_truncated) = truncate_tool_result(&result);
let _ = entry
.push_event(ChatStreamEvent::ToolResult {
index: call_index,
name: tool_call.function.name.clone(),
result: result_preview,
result_truncated,
})
.await;
messages.push(ChatMessage::tool_result(result));
}
continue;
}
final_content = response.content;
break;
}
if final_content.is_empty() {
let synthetic_idx = push_synthetic_final_prompt(messages);
let mut stream = backend
.chat()
.chat_with_tools_stream(messages.clone(), vec![])
.await?;
let mut final_message: Option<ChatMessage> = None;
while let Some(ev) = stream.next().await {
let ev = ev?;
match ev {
LlmStreamEvent::TextDelta(delta) => {
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
}
LlmStreamEvent::Done {
message,
prompt_eval_count,
eval_count,
} => {
last_prompt_eval_count = prompt_eval_count;
last_eval_count = eval_count;
final_message = Some(message);
break;
}
}
}
let final_response =
final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?;
final_content = final_response.content.clone();
messages.push(final_response);
remove_synthetic_final_prompt(messages, synthetic_idx);
}
Ok(AgenticLoopOutcome {
tool_calls_made,
iterations_used,
last_prompt_eval_count,
last_eval_count,
final_content: crate::ai::llm_client::strip_think_blocks(&final_content),
cancelled: false,
})
}
/// index the caller must later hand to [`remove_synthetic_final_prompt`]. /// index the caller must later hand to [`remove_synthetic_final_prompt`].
/// Used when the agentic loop exhausts its budget: the model gets one more /// Used when the agentic loop exhausts its budget: the model gets one more
/// (tool-free) request, but the nudge itself must never persist — it would /// (tool-free) request, but the nudge itself must never persist — it would
@@ -2499,7 +2543,7 @@ pub struct ForkInfo {
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to /// tool-dispatch assistant (empty content + tool_calls) is still attributed to
/// the next rendered message. Detecting forks only on rendered nodes would miss /// the next rendered message. Detecting forks only on rendered nodes would miss
/// regenerations where the model replied with a tool call. /// regenerations where the model replied with a tool call.
fn render_tree_path( pub(crate) fn render_tree_path(
store: &ChatHistoryStore, store: &ChatHistoryStore,
path: &[&StoredChatNode], path: &[&StoredChatNode],
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) { ) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {
@@ -2638,9 +2682,9 @@ pub struct ToolInvocation {
/// Soft cap for tool-result bodies returned via the history API. Keeps /// Soft cap for tool-result bodies returned via the history API. Keeps
/// payloads small for the mobile client — verbose SMS / geocoding responses /// payloads small for the mobile client — verbose SMS / geocoding responses
/// don't need to ship in full for inspection. /// don't need to ship in full for inspection.
const TOOL_RESULT_PREVIEW_MAX: usize = 2000; pub(crate) const TOOL_RESULT_PREVIEW_MAX: usize = 2000;
fn truncate_tool_result(s: &str) -> (String, bool) { pub(crate) fn truncate_tool_result(s: &str) -> (String, bool) {
if s.len() <= TOOL_RESULT_PREVIEW_MAX { if s.len() <= TOOL_RESULT_PREVIEW_MAX {
(s.to_string(), false) (s.to_string(), false)
} else { } else {
+2 -2
View File
@@ -407,8 +407,8 @@ impl ChatHistoryStore {
.collect() .collect()
} }
/// The parent of the given node, if any. Test-only helper. /// The parent of the given node, if any. Used to re-anchor a rewind
#[cfg(test)] /// that discards every rendered message onto the seed node above them.
pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> { pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> {
let node = self.nodes.iter().find(|n| n.id == node_id)?; let node = self.nodes.iter().find(|n| n.id == node_id)?;
let parent_id = node.parent_id?; let parent_id = node.parent_id?;
+7
View File
@@ -13,6 +13,7 @@ pub mod local_llm;
pub mod nl_query; pub mod nl_query;
pub mod ollama; pub mod ollama;
pub mod openrouter; pub mod openrouter;
pub mod persona_chat;
pub mod pronunciation; pub mod pronunciation;
pub mod sms_client; pub mod sms_client;
pub mod tts; pub mod tts;
@@ -38,6 +39,12 @@ pub use llamacpp::LlamaCppClient;
pub use llm_client::{ pub use llm_client::{
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction, ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
}; };
pub use persona_chat::{
persona_chat_branches_handler, persona_chat_conversations_handler,
persona_chat_create_conversation_handler, persona_chat_delete_conversation_handler,
persona_chat_history_handler, persona_chat_rewind_handler, persona_chat_switch_branch_handler,
persona_chat_turn_handler, persona_turn_cancel_handler, persona_turn_replay_handler,
};
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server // LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
#[allow(unused_imports)] #[allow(unused_imports)]
pub use local_llm::LocalLlm; pub use local_llm::LocalLlm;
File diff suppressed because it is too large Load Diff
+106
View File
@@ -52,6 +52,10 @@ pub struct TurnInfo {
pub turn_id: String, pub turn_id: String,
pub file_path: String, pub file_path: String,
pub library_id: i32, pub library_id: i32,
/// Persona id for persona-scoped turns. None for insight-scoped turns.
pub persona_id: Option<String>,
/// `"insight"` (file-anchored chat) | `"persona"` (open chat).
pub scope: String,
pub status: TurnStatus, pub status: TurnStatus,
pub total_events_pushed: u32, pub total_events_pushed: u32,
pub buffered_count: u32, pub buffered_count: u32,
@@ -78,8 +82,20 @@ pub enum ReplayOutcome {
/// replay connections (readers). /// replay connections (readers).
pub struct TurnEntry { pub struct TurnEntry {
pub turn_id: String, pub turn_id: String,
/// Stable identity used to scope the turn. For insight chat this is
/// `(file_path, library_id)`. For persona chat it is `(persona_id)`.
/// The generic fields stay populated regardless of scope so the SSE
/// `turn_info` frame can always surface a useful label — a future
/// "list active turns" debugging surface won't need to discriminate.
pub file_path: String, pub file_path: String,
pub library_id: i32, pub library_id: i32,
/// Persona id for persona-scoped turns (`scope == "persona"`). None for
/// the existing insight-scoped turns. The field is the cheapest way to
/// let the SSE `turn_info` payload round-trip without a parallel map.
pub persona_id: Option<String>,
/// `"insight"` (default — file-anchored chat) | `"persona"` (open chat).
/// Stable string so the SSE `turn_info` payload can carry it as-is.
pub scope: String,
/// Shared event buffer — multiple SSE connections can read independently. /// Shared event buffer — multiple SSE connections can read independently.
/// Each connection tracks its own `skip_before` offset. /// Each connection tracks its own `skip_before` offset.
events: Mutex<Vec<ChatStreamEvent>>, events: Mutex<Vec<ChatStreamEvent>>,
@@ -100,10 +116,35 @@ pub struct TurnEntry {
impl TurnEntry { impl TurnEntry {
pub fn new(turn_id: String, file_path: String, library_id: i32) -> Self { pub fn new(turn_id: String, file_path: String, library_id: i32) -> Self {
Self::with_scope(turn_id, file_path, library_id, None, "insight")
}
/// Persona-scoped constructor. `file_path` is unused by the persona
/// chat loop but kept on the struct so the SSE `turn_info` frame has
/// the same shape across scopes.
pub fn new_persona(turn_id: String, persona_id: String, library_id: i32) -> Self {
Self::with_scope(
turn_id,
String::new(),
library_id,
Some(persona_id),
"persona",
)
}
fn with_scope(
turn_id: String,
file_path: String,
library_id: i32,
persona_id: Option<String>,
scope: &str,
) -> Self {
Self { Self {
turn_id, turn_id,
file_path, file_path,
library_id, library_id,
persona_id,
scope: scope.to_string(),
events: Mutex::new(Vec::new()), events: Mutex::new(Vec::new()),
total_events_pushed: AtomicU32::new(0), total_events_pushed: AtomicU32::new(0),
base_index: AtomicU32::new(0), base_index: AtomicU32::new(0),
@@ -170,6 +211,8 @@ impl TurnEntry {
turn_id: self.turn_id.clone(), turn_id: self.turn_id.clone(),
file_path: self.file_path.clone(), file_path: self.file_path.clone(),
library_id: self.library_id, library_id: self.library_id,
persona_id: self.persona_id.clone(),
scope: self.scope.clone(),
status: self.status.load(Ordering::Relaxed).into(), status: self.status.load(Ordering::Relaxed).into(),
total_events_pushed: total, total_events_pushed: total,
buffered_count: buffered, buffered_count: buffered,
@@ -745,4 +788,67 @@ mod tests {
let from_base = events_of(entry.replay_from(5).await); let from_base = events_of(entry.replay_from(5).await);
assert_eq!(from_base.len(), MAX_BUFFERED_EVENTS); assert_eq!(from_base.len(), MAX_BUFFERED_EVENTS);
} }
// ── Persona-scope regression guards ───────────────────────────
//
// The open-chat surface relies on `scope == "persona"` and the
// optional `persona_id` surviving a round-trip through `info()`.
// A future refactor that hard-codes "insight" or drops the field
// would silently break the SSE `turn_info` payload — guard it.
#[tokio::test]
async fn turn_entry_supports_persona_scope_label() {
let entry = Arc::new(TurnEntry::new_persona(
"tp1".to_string(),
"journal".to_string(),
1,
));
assert_eq!(entry.scope, "persona");
assert_eq!(entry.persona_id.as_deref(), Some("journal"));
let info = entry.info().await;
assert_eq!(info.scope, "persona");
assert_eq!(info.persona_id.as_deref(), Some("journal"));
// File-anchored fields stay blank — the persona chat has no file.
assert!(info.file_path.is_empty());
}
#[tokio::test]
async fn turn_info_payload_for_persona_does_not_leak_file_path() {
// Defensive: even if some future code path accidentally populates
// file_path on a persona entry, the SSE `turn_info` payload
// should not surface it to clients. Today file_path is always
// empty for persona entries, so we assert that contract.
let entry = Arc::new(TurnEntry::new_persona(
"tp2".to_string(),
"default".to_string(),
1,
));
let info = entry.info().await;
let json = serde_json::to_string(&serde_json::json!({
"turn_id": info.turn_id,
"persona_id": info.persona_id,
"scope": info.scope,
"status": info.status.as_str(),
"total_events_pushed": info.total_events_pushed,
"buffered_count": info.buffered_count,
}))
.unwrap();
// Privacy: a persona chat reply that quotes an SMS must not echo
// the file path that message originally surfaced from.
assert!(
!json.contains("file_path"),
"persona turn_info payload should not leak file_path: {json}"
);
}
#[tokio::test]
async fn insight_scope_defaults_when_using_legacy_constructor() {
// The legacy `TurnEntry::new` constructor (used by the existing
// file-anchored insight chat) must still report scope="insight"
// so callers can discriminate without a parallel map.
let entry = Arc::new(TurnEntry::new("t-insight".into(), "/p.jpg".into(), 1));
let info = entry.info().await;
assert_eq!(info.scope, "insight");
assert!(info.persona_id.is_none());
}
} }
+491 -3
View File
@@ -10,6 +10,51 @@ use crate::database::schema;
use crate::database::{DbError, DbErrorKind, connect}; use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call; use crate::otel::trace_db_call;
/// One row of the persona-chat transcript. Lives in
/// `persona_chat_conversations` (one row per `(user_id, persona_id)`).
///
/// One persona conversation.
///
/// `turn_count` is the cumulative number of assistant turns on the active
/// branch — what the list screen shows as the length of a conversation. The
/// transcript itself is the `messages_json` blob, holding a serialized
/// `ChatHistoryStore` tree. `title` is a generated summary of the opening
/// exchange, empty until the first turn completes.
#[derive(Clone, Debug)]
pub struct PersonaChatRow {
pub conversation_id: String,
pub persona_id: String,
pub title: String,
pub messages_json: String,
pub turn_count: i32,
pub created_at: i64,
pub updated_at: i64,
}
/// Column tuple → `PersonaChatRow`. Shared by the single-row and list
/// queries so their `select(...)` orders can never drift apart.
fn persona_chat_row(
(conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at): (
String,
String,
String,
String,
i32,
i64,
i64,
),
) -> PersonaChatRow {
PersonaChatRow {
conversation_id,
persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
}
}
/// Patch shape for update_persona. None = leave field alone. Built-ins are /// Patch shape for update_persona. None = leave field alone. Built-ins are
/// allowed to flip `include_all_memories` but should reject name/prompt /// allowed to flip `include_all_memories` but should reject name/prompt
/// edits at the handler layer (built-in copy lives in the migration). /// edits at the handler layer (built-in copy lives in the migration).
@@ -80,6 +125,76 @@ pub trait PersonaDao: Sync + Send {
user_id: i32, user_id: i32,
personas: &[ImportPersona], personas: &[ImportPersona],
) -> Result<usize, DbError>; ) -> Result<usize, DbError>;
// ── Persona-chat (open chat with persona) persistence ───────────
//
// Keyed on an opaque `conversation_id` so one persona can hold several
// separate conversations. `user_id` is checked on every read and write:
// the id is a bearer token for a transcript, so ownership can never be
// assumed from the id alone.
/// Fetch one conversation. None when it doesn't exist or belongs to
/// another user — the two are deliberately indistinguishable to callers.
fn get_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
) -> Result<Option<PersonaChatRow>, DbError>;
/// Every conversation this user has going, newest first. Backs the chat
/// list screen, so it returns the transcript blob too — the snippet is
/// derived from the tree rather than denormalized into its own column.
fn list_persona_chats(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
) -> Result<Vec<PersonaChatRow>, DbError>;
/// Start a new conversation with a persona, returning its id. Several
/// conversations with the same persona are expected, so this never
/// reuses an existing row.
fn create_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
created_at: i64,
) -> Result<String, DbError>;
/// Replace a conversation's transcript. Called once per completed turn
/// with the full new `messages_json`; `turn_count` is the cumulative
/// assistant-turn count on the resulting active branch. Returns the
/// number of rows written — 0 means the conversation is gone or is not
/// this user's.
fn update_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
messages_json: &str,
turn_count: i32,
updated_at: i64,
) -> Result<usize, DbError>;
/// Name a conversation. Written once, after the first turn produces
/// enough of an exchange to summarize.
fn set_persona_chat_title(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
title: &str,
) -> Result<(), DbError>;
/// Delete a conversation outright. Backs both the list screen's delete
/// affordance and the reset endpoint.
fn delete_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
) -> Result<(), DbError>;
} }
pub struct SqlitePersonaDao { pub struct SqlitePersonaDao {
@@ -251,13 +366,29 @@ impl PersonaDao for SqlitePersonaDao {
pid: &str, pid: &str,
) -> Result<bool, DbError> { ) -> Result<bool, DbError> {
trace_db_call(cx, "delete", "delete_persona", |_span| { trace_db_call(cx, "delete", "delete_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock"); let mut conn = self.connection.lock().expect("PersonaDao lock");
// One transaction so a persona and its conversations can't get
// out of step. There is no FK between the two tables, and the
// chat list skips conversations whose persona is gone — so
// without this the transcripts would linger invisibly forever.
conn.deref_mut().transaction::<_, anyhow::Error, _>(|tx| {
{
use schema::persona_chat_conversations::dsl::*;
diesel::delete(
persona_chat_conversations
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid)),
)
.execute(tx)
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
}
use schema::personas::dsl::*;
let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.execute(conn.deref_mut()) .execute(tx)
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
Ok(n > 0) Ok(n > 0)
}) })
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
@@ -296,8 +427,168 @@ impl PersonaDao for SqlitePersonaDao {
}) })
.map_err(|e| DbError::log(DbErrorKind::InsertError, e)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
}
fn get_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
) -> Result<Option<PersonaChatRow>, DbError> {
trace_db_call(cx, "query", "get_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
persona_chat_conversations
.filter(conversation_id.eq(cid))
// Scoped by user as well as id: the id is a bearer token for
// someone's transcript, so it never grants access on its own.
.filter(user_id.eq(uid))
.select((
conversation_id,
persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
))
.first::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
.optional()
.map(|opt| opt.map(persona_chat_row))
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn list_persona_chats(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
) -> Result<Vec<PersonaChatRow>, DbError> {
trace_db_call(cx, "query", "list_persona_chats", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
persona_chat_conversations
.filter(user_id.eq(uid))
.order(updated_at.desc())
.select((
conversation_id,
persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
))
.load::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
.map(|rows| rows.into_iter().map(persona_chat_row).collect())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn create_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
created: i64,
) -> Result<String, DbError> {
trace_db_call(cx, "insert", "create_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let new_id = uuid::Uuid::new_v4().to_string();
diesel::insert_into(persona_chat_conversations)
.values((
conversation_id.eq(&new_id),
user_id.eq(uid),
persona_id.eq(pid),
title.eq(""),
// The empty tree; the first turn seeds it with the
// persona's system prompt.
messages_json.eq("[]"),
turn_count.eq(0),
created_at.eq(created),
updated_at.eq(created),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
Ok(new_id)
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn update_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
json: &str,
count: i32,
updated: i64,
) -> Result<usize, DbError> {
trace_db_call(cx, "update", "update_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::update(
persona_chat_conversations
.filter(conversation_id.eq(cid))
.filter(user_id.eq(uid)),
)
.set((
messages_json.eq(json),
turn_count.eq(count),
updated_at.eq(updated),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn set_persona_chat_title(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
new_title: &str,
) -> Result<(), DbError> {
trace_db_call(cx, "update", "set_persona_chat_title", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::update(
persona_chat_conversations
.filter(conversation_id.eq(cid))
.filter(user_id.eq(uid)),
)
.set(title.eq(new_title))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
Ok(())
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn delete_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
) -> Result<(), DbError> {
trace_db_call(cx, "delete", "delete_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::delete(
persona_chat_conversations
.filter(conversation_id.eq(cid))
.filter(user_id.eq(uid)),
)
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
Ok(())
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -444,4 +735,201 @@ mod tests {
.unwrap(); .unwrap();
assert!(updated.include_all_memories); assert!(updated.include_all_memories);
} }
// ── Persona-chat DAO tests ─────────────────────────────────────
/// Second user, for the isolation tests.
fn second_user(dao: &SqlitePersonaDao, username: &str) -> i32 {
use crate::database::schema::users::dsl as u;
let conn = dao.connection.clone();
diesel::insert_into(u::users)
.values((u::username.eq(username), u::password.eq("x")))
.execute(conn.lock().unwrap().deref_mut())
.unwrap();
u::users
.filter(u::username.eq(username))
.select(u::id)
.first(conn.lock().unwrap().deref_mut())
.unwrap()
}
#[test]
fn persona_chat_get_returns_none_for_an_unknown_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p1");
assert!(
dao.get_persona_chat(&cx, uid, "no-such-id")
.unwrap()
.is_none()
);
}
#[test]
fn persona_chat_create_then_get_round_trip() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p2");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
assert_eq!(row.conversation_id, cid);
assert_eq!(row.persona_id, "journal");
assert_eq!(row.title, "", "unnamed until the first turn completes");
assert_eq!(row.turn_count, 0);
assert_eq!(row.created_at, 100);
assert_eq!(row.updated_at, 100);
}
#[test]
fn persona_chat_create_makes_a_separate_row_each_time() {
// The whole point of the conversation_id key: one persona, several
// independent threads.
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p3");
let first = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let second = dao.create_persona_chat(&cx, uid, "journal", 200).unwrap();
assert_ne!(first, second);
assert_eq!(dao.list_persona_chats(&cx, uid).unwrap().len(), 2);
}
#[test]
fn persona_chat_update_replaces_the_transcript() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p4");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let rows = dao
.update_persona_chat(&cx, uid, &cid, "first", 1, 150)
.unwrap();
assert_eq!(rows, 1);
let rows = dao
.update_persona_chat(&cx, uid, &cid, "second", 2, 200)
.unwrap();
assert_eq!(rows, 1);
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "second");
assert_eq!(row.turn_count, 2);
assert_eq!(row.updated_at, 200);
assert_eq!(row.created_at, 100, "creation time is not disturbed");
}
#[test]
fn persona_chat_update_reports_zero_rows_for_an_unknown_conversation() {
// The turn loop treats 0 as "the conversation went away mid-turn"
// rather than silently succeeding.
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p5");
let rows = dao
.update_persona_chat(&cx, uid, "no-such-id", "x", 1, 1)
.unwrap();
assert_eq!(rows, 0);
}
#[test]
fn persona_chat_set_title_names_the_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p6");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
dao.set_persona_chat_title(&cx, uid, &cid, "June recap")
.unwrap();
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
assert_eq!(row.title, "June recap");
}
#[test]
fn persona_chat_list_orders_newest_first() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p7");
let older = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let newer = dao.create_persona_chat(&cx, uid, "coach", 300).unwrap();
let middle = dao.create_persona_chat(&cx, uid, "default", 200).unwrap();
let ids: Vec<String> = dao
.list_persona_chats(&cx, uid)
.unwrap()
.into_iter()
.map(|r| r.conversation_id)
.collect();
assert_eq!(
ids,
vec![newer, middle, older],
"ordered by updated_at descending so the list screen needs no re-sort"
);
}
#[test]
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p8");
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
}
#[test]
fn persona_chat_delete_removes_the_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p9");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
dao.delete_persona_chat(&cx, uid, &cid).unwrap();
assert!(dao.get_persona_chat(&cx, uid, &cid).unwrap().is_none());
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
}
#[test]
fn deleting_a_persona_takes_its_conversations_with_it() {
// No FK between the tables, and the chat list hides conversations
// whose persona is gone — so an orphan row would be an invisible,
// unreachable transcript sitting in the database forever.
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p-cascade");
dao.create_persona(&cx, uid, "custom-1", "Custom", "prompt", false, false)
.unwrap();
dao.create_persona_chat(&cx, uid, "custom-1", 100).unwrap();
dao.create_persona_chat(&cx, uid, "custom-1", 200).unwrap();
let survivor = dao.create_persona_chat(&cx, uid, "other", 300).unwrap();
assert!(dao.delete_persona(&cx, uid, "custom-1").unwrap());
let remaining: Vec<String> = dao
.list_persona_chats(&cx, uid)
.unwrap()
.into_iter()
.map(|r| r.conversation_id)
.collect();
assert_eq!(
remaining,
vec![survivor],
"only the untouched persona's conversation survives"
);
}
#[test]
fn persona_chat_reads_and_writes_are_scoped_to_the_owner() {
// A conversation_id is a bearer token for someone's transcript, so
// holding one must not grant another user access to it.
let cx = opentelemetry::Context::new();
let (mut dao, uid1) = dao_with_user("owner");
let uid2 = second_user(&dao, "intruder");
let cid = dao.create_persona_chat(&cx, uid1, "journal", 100).unwrap();
dao.update_persona_chat(&cx, uid1, &cid, "private", 1, 100)
.unwrap();
assert!(
dao.get_persona_chat(&cx, uid2, &cid).unwrap().is_none(),
"another user cannot read it"
);
assert_eq!(
dao.update_persona_chat(&cx, uid2, &cid, "tampered", 9, 999)
.unwrap(),
0,
"another user cannot write it"
);
dao.delete_persona_chat(&cx, uid2, &cid).unwrap();
let row = dao.get_persona_chat(&cx, uid1, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "private", "and cannot delete it");
assert!(dao.list_persona_chats(&cx, uid2).unwrap().is_empty());
}
} }
+14
View File
@@ -171,6 +171,19 @@ diesel::table! {
} }
} }
diesel::table! {
persona_chat_conversations (conversation_id) {
conversation_id -> Text,
user_id -> Integer,
persona_id -> Text,
title -> Text,
messages_json -> Text,
turn_count -> Integer,
created_at -> BigInt,
updated_at -> BigInt,
}
}
diesel::table! { diesel::table! {
personas (id) { personas (id) {
id -> Integer, id -> Integer,
@@ -345,6 +358,7 @@ diesel::allow_tables_to_appear_in_same_query!(
insight_generation_jobs, insight_generation_jobs,
libraries, libraries,
location_history, location_history,
persona_chat_conversations,
personas, personas,
persons, persons,
photo_insights, photo_insights,
+10
View File
@@ -382,6 +382,16 @@ fn main() -> std::io::Result<()> {
.service(ai::turn_async_handler) .service(ai::turn_async_handler)
.service(ai::turn_replay_handler) .service(ai::turn_replay_handler)
.service(ai::cancel_turn_handler) .service(ai::cancel_turn_handler)
.service(ai::persona_chat_history_handler)
.service(ai::persona_chat_turn_handler)
.service(ai::persona_chat_create_conversation_handler)
.service(ai::persona_chat_delete_conversation_handler)
.service(ai::persona_turn_replay_handler)
.service(ai::persona_turn_cancel_handler)
.service(ai::persona_chat_rewind_handler)
.service(ai::persona_chat_switch_branch_handler)
.service(ai::persona_chat_branches_handler)
.service(ai::persona_chat_conversations_handler)
.service(ai::rate_insight_handler) .service(ai::rate_insight_handler)
.service(ai::export_training_data_handler) .service(ai::export_training_data_handler)
.service(ai::tts_speech_handler) .service(ai::tts_speech_handler)
+24 -2
View File
@@ -4,6 +4,7 @@ use crate::ai::face_client::FaceClient;
use crate::ai::insight_chat::{ChatLockMap, InsightChatService}; use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
use crate::ai::llamacpp::LlamaCppClient; use crate::ai::llamacpp::LlamaCppClient;
use crate::ai::openrouter::OpenRouterClient; use crate::ai::openrouter::OpenRouterClient;
use crate::ai::persona_chat::PersonaChatSession;
use crate::ai::turn_registry::TurnRegistry; use crate::ai::turn_registry::TurnRegistry;
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient}; use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
use crate::database::{ use crate::database::{
@@ -84,6 +85,9 @@ pub struct AppState {
pub insight_generator: InsightGenerator, pub insight_generator: InsightGenerator,
/// Chat continuation service. Hold an Arc so handlers can clone cheaply. /// Chat continuation service. Hold an Arc so handlers can clone cheaply.
pub insight_chat: Arc<InsightChatService>, pub insight_chat: Arc<InsightChatService>,
/// Open chat with persona service. Same shape as insight_chat but
/// anchored on (user_id, persona_id) instead of (library_id, file_path).
pub persona_chat_session: Arc<PersonaChatSession>,
pub turn_registry: Arc<TurnRegistry>, pub turn_registry: Arc<TurnRegistry>,
pub face_client: FaceClient, pub face_client: FaceClient,
pub clip_client: ClipClient, pub clip_client: ClipClient,
@@ -133,6 +137,7 @@ impl AppState {
sms_client: SmsApiClient, sms_client: SmsApiClient,
insight_generator: InsightGenerator, insight_generator: InsightGenerator,
insight_chat: Arc<InsightChatService>, insight_chat: Arc<InsightChatService>,
persona_chat_session: Arc<PersonaChatSession>,
turn_registry: Arc<TurnRegistry>, turn_registry: Arc<TurnRegistry>,
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>, preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
face_client: FaceClient, face_client: FaceClient,
@@ -194,6 +199,7 @@ impl AppState {
sms_client, sms_client,
insight_generator, insight_generator,
insight_chat, insight_chat,
persona_chat_session,
turn_registry, turn_registry,
face_client, face_client,
clip_client, clip_client,
@@ -316,7 +322,7 @@ impl Default for AppState {
tag_dao.clone(), tag_dao.clone(),
face_dao.clone(), face_dao.clone(),
knowledge_dao, knowledge_dao,
persona_dao, persona_dao.clone(),
libraries_vec.clone(), libraries_vec.clone(),
); );
@@ -330,6 +336,13 @@ impl Default for AppState {
chat_locks, chat_locks,
)); ));
// Open chat with persona: reuses the generator + persona DAO.
let persona_chat_session = Arc::new(PersonaChatSession::new(
Arc::new(insight_generator.clone()),
persona_dao.clone(),
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
));
// Turn registry for reconnectable chat turns. 5-minute timeout for // Turn registry for reconnectable chat turns. 5-minute timeout for
// stale turns (background cleaner drops entries older than this). // stale turns (background cleaner drops entries older than this).
let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS") let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS")
@@ -360,6 +373,7 @@ impl Default for AppState {
sms_client, sms_client,
insight_generator, insight_generator,
insight_chat, insight_chat,
persona_chat_session,
turn_registry, turn_registry,
preview_dao, preview_dao,
face_client, face_client,
@@ -528,7 +542,7 @@ impl AppState {
tag_dao.clone(), tag_dao.clone(),
face_dao.clone(), face_dao.clone(),
knowledge_dao, knowledge_dao,
persona_dao, persona_dao.clone(),
vec![test_lib], vec![test_lib],
); );
@@ -540,6 +554,13 @@ impl AppState {
chat_locks, chat_locks,
)); ));
// Open chat with persona (test).
let persona_chat_session = Arc::new(PersonaChatSession::new(
Arc::new(insight_generator.clone()),
persona_dao.clone(),
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
));
// Turn registry for test state. // Turn registry for test state.
let turn_registry = Arc::new(TurnRegistry::new(300)); let turn_registry = Arc::new(TurnRegistry::new(300));
@@ -571,6 +592,7 @@ impl AppState {
sms_client, sms_client,
insight_generator, insight_generator,
insight_chat, insight_chat,
persona_chat_session,
turn_registry, turn_registry,
preview_dao, preview_dao,
FaceClient::new(None), // disabled in test FaceClient::new(None), // disabled in test
+1 -5
View File
@@ -175,11 +175,7 @@ fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<(
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors /// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales /// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
/// to the large-preview cap instead of 200. /// to the large-preview cap instead of 200.
fn generate_large_preview_ffmpeg( fn generate_large_preview_ffmpeg(src: &Path, dest: &Path, orientation: i32) -> std::io::Result<()> {
src: &Path,
dest: &Path,
orientation: i32,
) -> std::io::Result<()> {
// Rotation + scale + colorspace. HEIC sources use Display P3; without // Rotation + scale + colorspace. HEIC sources use Display P3; without
// colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing // colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing
// warm/oversaturated output. The min(iw,cap) trick caps the long edge // warm/oversaturated output. The min(iw,cap) trick caps the long edge