feat: add /persona_chat endpoints for open chat with persona

- PersonaChatSession dispatches turns through the shared agent loop,
  persisting transcripts keyed on (user_id, persona_id) in the new
  persona_chat table (migration included).
- GET /persona_chat/history returns a rendered transcript (tool
  invocations folded, is_initial flag) matching the file-chat shape.
- POST /persona_chat/turn returns 202 with a turn_id; SSE replay and
  cancel reuse turn_replay_impl/cancel_turn_impl, extracted from the
  actix-attributed handlers so both route families share the logic.
- POST /persona_chat/reset clears the persona transcript.
- Concurrent dispatches for the same (user, persona) are rejected with
  409 via an in-flight gate (InFlightPersonaTurns) whose RAII guard
  drops with the spawned turn task, freeing the slot on completion,
  error, or abort.
This commit is contained in:
Cameron Cordes
2026-08-24 21:25:48 -04:00
parent 649863a700
commit 3b9d985025
11 changed files with 1716 additions and 179 deletions
@@ -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);
+56 -10
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();
@@ -2012,11 +2051,18 @@ pub async fn cancel_turn_handler(
entry.set_terminal_status(crate::ai::turn_registry::TurnStatus::Cancelled); entry.set_terminal_status(crate::ai::turn_registry::TurnStatus::Cancelled);
span.set_status(Status::Ok); span.set_status(Status::Ok);
HttpResponse::Ok().json(serde_json::json!({ HttpResponse::Ok().json(serde_json::json!({
"cancelled": true "cancelled": true
})) }))
} }
// 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};
+196 -161
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
@@ -1303,7 +1303,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 +1320,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 +2076,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 +2208,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())
@@ -2395,6 +2262,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
@@ -2638,9 +2673,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 {
+5
View File
@@ -7,6 +7,7 @@ pub mod gpu;
pub mod handlers; pub mod handlers;
pub mod insight_chat; pub mod insight_chat;
pub mod insight_generator; pub mod insight_generator;
pub mod persona_chat;
pub mod llamacpp; pub mod llamacpp;
pub mod llm_client; pub mod llm_client;
pub mod local_llm; pub mod local_llm;
@@ -32,6 +33,10 @@ pub use handlers::{
get_available_models_handler, get_insight_handler, get_insight_history_handler, get_available_models_handler, get_insight_handler, get_insight_history_handler,
get_openrouter_models_handler, rate_insight_handler, turn_async_handler, turn_replay_handler, get_openrouter_models_handler, rate_insight_handler, turn_async_handler, turn_replay_handler,
}; };
pub use persona_chat::{
persona_chat_history_handler, persona_chat_reset_handler, persona_chat_turn_handler,
persona_turn_cancel_handler, persona_turn_replay_handler,
};
pub use insight_generator::InsightGenerator; pub use insight_generator::InsightGenerator;
pub use llamacpp::LlamaCppClient; pub use llamacpp::LlamaCppClient;
#[allow(unused_imports)] #[allow(unused_imports)]
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());
}
} }
+213
View File
@@ -10,6 +10,19 @@ 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)`).
///
/// `turn_count` is the number of new turn-rows since the last persisted
/// slice — used by the SSE `done` event for stats dashboards. The actual
/// transcript is the `messages_json` blob.
#[derive(Clone, Debug)]
pub struct PersonaChatRow {
pub messages_json: String,
pub turn_count: i32,
pub updated_at: i64,
}
/// 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 +93,43 @@ 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 by `(user_id, persona_id)` with a single rolling transcript
// per pair. No tree branching in v1 — matches the locked-in scope.
/// Fetch the rolling transcript for one `(user, persona)`. None when
/// the user has never started a conversation with this persona.
fn get_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
) -> Result<Option<PersonaChatRow>, DbError>;
/// Upsert (create-or-replace) the rolling transcript. Called once per
/// completed turn with the full new `messages_json`. The `turn_count`
/// is the number of user/assistant pairs added in this write.
fn upsert_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
messages_json: &str,
turn_count: i32,
updated_at: i64,
) -> Result<(), DbError>;
/// Wipe the rolling transcript. Reserved for a future "New
/// conversation" affordance; the row itself stays (so a subsequent
/// get returns None, not 404).
fn clear_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
) -> Result<(), DbError>;
} }
pub struct SqlitePersonaDao { pub struct SqlitePersonaDao {
@@ -296,6 +346,96 @@ 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,
pid: &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(user_id.eq(uid))
.filter(persona_id.eq(pid))
.select((messages_json, turn_count, updated_at))
.first::<(String, i32, i64)>(conn.deref_mut())
.optional()
.map(|opt| {
opt.map(|(m, t, u)| PersonaChatRow {
messages_json: m,
turn_count: t,
updated_at: u,
})
})
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn upsert_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
json: &str,
count: i32,
updated_at: i64,
) -> Result<(), DbError> {
trace_db_call(cx, "upsert", "upsert_persona_chat", |_span| {
let mut conn = self.connection.lock().expect("PersonaDao lock");
// INSERT OR REPLACE on the (user_id, persona_id) PRIMARY KEY —
// single rolling transcript, so a new write always supersedes
// the prior one in full. The mobile hook serialises turns with
// a per-persona mutex, so this never races. Plain
// `sql_query` sidesteps a Diesel type-recursion blow-up that
// hits `insert_into(...).on_conflict(...).do_update().set(...)`
// with this many typed columns.
diesel::sql_query(
"INSERT INTO persona_chat_conversations \
(user_id, persona_id, messages_json, turn_count, updated_at) \
VALUES (?, ?, ?, ?, ?) \
ON CONFLICT(user_id, persona_id) DO UPDATE SET \
messages_json = excluded.messages_json, \
turn_count = excluded.turn_count, \
updated_at = excluded.updated_at",
)
.bind::<diesel::sql_types::Integer, _>(uid)
.bind::<diesel::sql_types::Text, _>(pid)
.bind::<diesel::sql_types::Text, _>(json)
.bind::<diesel::sql_types::Integer, _>(count)
.bind::<diesel::sql_types::BigInt, _>(updated_at)
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Upsert error: {}", e))?;
Ok(())
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn clear_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
) -> Result<(), DbError> {
trace_db_call(cx, "delete", "clear_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
// Delete the row entirely so the next get returns None.
// The next send will INSERT a fresh seed (system + greeting)
// via the same code path a brand-new persona uses.
diesel::delete(
persona_chat_conversations
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid)),
)
.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)]
@@ -444,4 +584,77 @@ mod tests {
.unwrap(); .unwrap();
assert!(updated.include_all_memories); assert!(updated.include_all_memories);
} }
// ── Persona-chat DAO tests ─────────────────────────────────────
#[test]
fn persona_chat_get_returns_none_for_never_started() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p1");
let row = dao.get_persona_chat(&cx, uid, "default").unwrap();
assert!(row.is_none());
}
#[test]
fn persona_chat_upsert_then_get_round_trip() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p2");
dao.upsert_persona_chat(&cx, uid, "journal", "[]", 1, 100)
.unwrap();
let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap();
assert_eq!(row.messages_json, "[]");
assert_eq!(row.turn_count, 1);
assert_eq!(row.updated_at, 100);
}
#[test]
fn persona_chat_upsert_replaces_existing_row() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p3");
dao.upsert_persona_chat(&cx, uid, "journal", "first", 1, 100).unwrap();
dao.upsert_persona_chat(&cx, uid, "journal", "second", 2, 200).unwrap();
let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap();
assert_eq!(row.messages_json, "second");
assert_eq!(row.turn_count, 2);
assert_eq!(row.updated_at, 200);
// Single rolling transcript → exactly one row per (user, persona).
}
#[test]
fn persona_chat_isolation_between_users() {
let cx = opentelemetry::Context::new();
let (mut dao, uid1) = dao_with_user("u1");
let uid2: i32 = {
let conn = dao.connection.clone();
use crate::database::schema::users::dsl as u;
diesel::insert_into(u::users)
.values((u::username.eq("u2"), u::password.eq("x")))
.execute(conn.lock().unwrap().deref_mut())
.unwrap();
u::users
.filter(u::username.eq("u2"))
.select(u::id)
.first(conn.lock().unwrap().deref_mut())
.unwrap()
};
dao.upsert_persona_chat(&cx, uid1, "default", "u1-row", 1, 1).unwrap();
dao.upsert_persona_chat(&cx, uid2, "default", "u2-row", 1, 2).unwrap();
assert_eq!(
dao.get_persona_chat(&cx, uid1, "default").unwrap().unwrap().messages_json,
"u1-row"
);
assert_eq!(
dao.get_persona_chat(&cx, uid2, "default").unwrap().unwrap().messages_json,
"u2-row"
);
}
#[test]
fn persona_chat_clear_wipes_the_row_so_get_returns_none() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p4");
dao.upsert_persona_chat(&cx, uid, "default", "[]", 1, 1).unwrap();
dao.clear_persona_chat(&cx, uid, "default").unwrap();
assert!(dao.get_persona_chat(&cx, uid, "default").unwrap().is_none());
}
} }
+11
View File
@@ -171,6 +171,16 @@ diesel::table! {
} }
} }
diesel::table! {
persona_chat_conversations (user_id, persona_id) {
user_id -> Integer,
persona_id -> Text,
messages_json -> Text,
turn_count -> Integer,
updated_at -> BigInt,
}
}
diesel::table! { diesel::table! {
personas (id) { personas (id) {
id -> Integer, id -> Integer,
@@ -345,6 +355,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,
+5
View File
@@ -382,6 +382,11 @@ 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_reset_handler)
.service(ai::persona_turn_replay_handler)
.service(ai::persona_turn_cancel_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