trace_db_call no longer takes an OpenTelemetry Context: it opens a db.<query_type>.<operation> span (same name via otel.name, same db.query_type / db.operation attrs) on the caller's ambient tracing span and runs the DAO body inside it. The cx / ctx / context parameter is gone from every DAO trait method, impl and call site, and from the helpers and background drains that only threaded it. The remaining request-handler spans (files, tags, faces, image, video, favorites, duplicates, memories) move to #[instrument]; the Context::current_with_span / with_remote_span_context plumbing is gone, as is extract_context_from_request (the tracing-actix-web root span covers it). otel::set_span_attributes replaces BoxedSpan::set_attributes. Background-job raw spans (thumbnails, video actors) are left for #142. tests/otel_db_spans.rs pins name, attributes, status and nesting (across an instrumented spawn); otel_traceparent now uses an #[instrument] handler. CLAUDE.md documents the tracing conventions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2182 lines
86 KiB
Rust
2182 lines
86 KiB
Rust
//! Open chat with persona — file-anchored insight chat's sibling.
|
|
//!
|
|
//! The persona chat is the same agentic loop, but anchored on a
|
|
//! `conversation_id` instead of `(library_id, file_path)`. A persona can
|
|
//! hold any number of separate conversations; each is a branching
|
|
//! transcript tree supporting rewind and fork, exactly like the file chat.
|
|
//!
|
|
//! The session reuses three building blocks from the file chat:
|
|
//! 1. `InsightGenerator::build_tool_definitions` for the tool catalog
|
|
//! (gated by the persona's `ToolGateOpts` — no `describe_photo`, no
|
|
//! `get_file_tags`, etc., because there is no file).
|
|
//! 2. `InsightChatService::run_streaming_agentic_loop_with_entry` for the
|
|
//! actual turn loop. It already pushes events to a `TurnEntry` and
|
|
//! handles cancellation; we just feed it a no-photo argument set.
|
|
//! 3. The shared `TurnRegistry` — extended with a `scope` label so the
|
|
//! SSE `turn_info` payload can distinguish the two.
|
|
//!
|
|
//! What this module owns:
|
|
//! - The persona-scoped persistence DAO.
|
|
//! - Pure helpers (prompt builder, validation gates, seed-message shape).
|
|
//! - The session that wires the agent loop to the persona DAO + turn
|
|
//! registry.
|
|
//! - The HTTP handlers under ` /persona_chat/*`.
|
|
|
|
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
|
use anyhow::{Context, Result, anyhow, bail};
|
|
use chrono::Utc;
|
|
use opentelemetry::trace::Status;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
use tracing::Instrument;
|
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
|
use uuid::Uuid;
|
|
|
|
use crate::ai::backend::{BackendKind, SamplingOverrides};
|
|
use crate::ai::handlers::ReplayQuery;
|
|
use crate::ai::insight_chat::{
|
|
BYTES_PER_TOKEN, ChatStreamEvent, DEFAULT_MAX_ITERATIONS, ForkInfo, RESPONSE_HEADROOM_TOKENS,
|
|
apply_context_budget, env_default_num_ctx, env_max_iterations, render_tree_path,
|
|
};
|
|
use crate::ai::insight_generator::InsightGenerator;
|
|
use crate::ai::llm_client::{ChatHistoryStore, ChatMessage, StoredChatNode};
|
|
use crate::ai::turn_registry::{TurnEntry, TurnRegistry};
|
|
use crate::data::Claims;
|
|
use crate::database::PersonaDao;
|
|
use crate::libraries;
|
|
use crate::state::AppState;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Errors
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub enum PersonaChatError {
|
|
UnknownPersona(String),
|
|
UnknownConversation(String),
|
|
EmptyMessage,
|
|
MessageTooLong,
|
|
ConcurrentTurn,
|
|
Db(anyhow::Error),
|
|
}
|
|
|
|
impl std::fmt::Display for PersonaChatError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
PersonaChatError::UnknownPersona(p) => write!(f, "persona '{p}' not found"),
|
|
PersonaChatError::UnknownConversation(c) => {
|
|
write!(f, "conversation '{c}' not found")
|
|
}
|
|
PersonaChatError::EmptyMessage => write!(f, "user_message must not be empty"),
|
|
PersonaChatError::MessageTooLong => write!(f, "user_message exceeds 8192 chars"),
|
|
PersonaChatError::ConcurrentTurn => {
|
|
write!(f, "another turn for this persona is in flight")
|
|
}
|
|
PersonaChatError::Db(e) => write!(f, "database error: {e}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PersonaChatError {}
|
|
|
|
impl From<anyhow::Error> for PersonaChatError {
|
|
fn from(e: anyhow::Error) -> Self {
|
|
PersonaChatError::Db(e)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Pure helpers — covered by unit tests at the bottom of this file.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// Resolve the system prompt for a persona chat turn.
|
|
///
|
|
/// In the v1 scope there's no per-turn persona override: the persona's
|
|
/// stored `systemPrompt` is authoritative. A future "voice override" can
|
|
/// extend this signature without touching the call sites.
|
|
pub fn resolve_persona_system_prompt(
|
|
persona: Option<&crate::database::models::Persona>,
|
|
) -> Option<String> {
|
|
persona
|
|
.filter(|p| !p.system_prompt.trim().is_empty())
|
|
.map(|p| p.system_prompt.clone())
|
|
}
|
|
|
|
/// Build the seed messages for a fresh persona conversation.
|
|
///
|
|
/// Just the system prompt. The file chat seeds a synthetic "describe this
|
|
/// photo" user turn because its transcript is anchored on an image; an open
|
|
/// chat has no anchor, so the user's own first question is message one.
|
|
///
|
|
/// v1 also seeded an assistant greeting here. It never reached the screen —
|
|
/// the flat renderer stripped it — and the tree renderer has no equivalent
|
|
/// stripping pass, so it is gone. `drop_seed_greeting` handles rows that
|
|
/// still carry one.
|
|
pub fn seed_messages_for_persona(system_prompt: &str) -> Vec<ChatMessage> {
|
|
vec![ChatMessage::system(system_prompt.to_string())]
|
|
}
|
|
|
|
/// Parse a persisted transcript into the conversation tree.
|
|
///
|
|
/// Two on-disk formats are accepted, mirroring the file chat's reader:
|
|
/// 1. `ChatHistoryStore` JSON — the tree, which is what every write produces
|
|
/// now.
|
|
/// 2. A flat `Vec<ChatMessage>` — rows written before the tree migration,
|
|
/// upgraded on read into a single linear branch.
|
|
pub fn decode_store(raw: &str) -> Result<ChatHistoryStore> {
|
|
if let Ok(flat) = serde_json::from_str::<Vec<ChatMessage>>(raw) {
|
|
return Ok(ChatHistoryStore::from_flat_array(drop_seed_greeting(flat)));
|
|
}
|
|
serde_json::from_str(raw).with_context(|| "failed to deserialize persona chat history")
|
|
}
|
|
|
|
pub fn encode_store(store: &ChatHistoryStore) -> Result<String> {
|
|
serde_json::to_string(store).with_context(|| "failed to serialize persona chat history")
|
|
}
|
|
|
|
/// Drop the v1 seed greeting from a pre-migration transcript.
|
|
///
|
|
/// The greeting was invisible under the flat renderer, which stripped every
|
|
/// leading system/greeting message. The tree renderer skips `system` nodes
|
|
/// but renders every assistant node, so without this the upgrade would
|
|
/// surface a bubble the user has never seen. Scoped to an assistant message
|
|
/// preceded only by system messages, so a genuine reply that happens to open
|
|
/// with the same words is left alone.
|
|
fn drop_seed_greeting(mut messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
|
if let Some(idx) = messages.iter().position(|m| m.role != "system")
|
|
&& messages[idx].role == "assistant"
|
|
&& is_seed_greeting(&messages[idx].content)
|
|
{
|
|
messages.remove(idx);
|
|
}
|
|
messages
|
|
}
|
|
|
|
fn is_seed_greeting(content: &str) -> bool {
|
|
content.starts_with("Hi — I'm") && content.contains("ready to help")
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Wire shapes — snake_case, matching the file-chat convention.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonaChatTurnRequest {
|
|
/// Conversation to append to. The persona is read from the stored
|
|
/// conversation, not the request — a transcript's voice is fixed when it
|
|
/// is created, so a stale client can't quietly swap it mid-thread.
|
|
pub conversation_id: String,
|
|
/// Free-text user message. Trimmed before validation; empty input is a 400.
|
|
pub user_message: String,
|
|
#[serde(default)]
|
|
pub model: Option<String>,
|
|
#[serde(default)]
|
|
pub backend: Option<String>,
|
|
#[serde(default)]
|
|
pub num_ctx: Option<i32>,
|
|
#[serde(default)]
|
|
pub temperature: Option<f32>,
|
|
#[serde(default)]
|
|
pub top_p: Option<f32>,
|
|
#[serde(default)]
|
|
pub top_k: Option<i32>,
|
|
#[serde(default)]
|
|
pub min_p: Option<f32>,
|
|
#[serde(default)]
|
|
pub enable_thinking: Option<bool>,
|
|
/// Per-turn system-prompt override. Appended to the persona's prompt
|
|
/// for this turn only; not persisted (persona voice survives a
|
|
/// voice-flipped turn). Empty / whitespace = no override.
|
|
#[serde(default)]
|
|
pub system_prompt: Option<String>,
|
|
#[serde(default)]
|
|
pub library: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PersonaChatHistoryView {
|
|
/// Conversation this transcript belongs to. Empty for the empty
|
|
/// envelope returned when the conversation has no messages yet.
|
|
pub conversation_id: String,
|
|
/// Persona whose voice this conversation uses.
|
|
pub persona_id: String,
|
|
/// Generated name for the conversation; empty until the first turn has
|
|
/// produced enough of an exchange to summarize.
|
|
pub title: String,
|
|
/// Rendered transcript — same message shape the file chat's history
|
|
/// endpoint ships, so the client's renderer is shared.
|
|
pub messages: Vec<RenderedPersonaMessage>,
|
|
pub turn_count: u32,
|
|
pub model_version: String,
|
|
pub backend: String,
|
|
/// Leaf the conversation is anchored to. A new turn extends this leaf.
|
|
pub active_leaf_id: u64,
|
|
/// Leaf actually being rendered — equals `active_leaf_id` unless the
|
|
/// client asked for an alternate branch via `branch_id`.
|
|
pub viewing_branch_id: u64,
|
|
/// Per-message divergence markers, `None` where the path doesn't fork.
|
|
/// Empty for a conversation that has never been rewound.
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub fork_info: Vec<Option<crate::ai::handlers::ChatForkInfo>>,
|
|
}
|
|
|
|
/// One rendered line of the persona transcript. Mirrors the file chat's
|
|
/// `RenderedHistoryMessage` wire shape: `tools` carries the tool
|
|
/// invocations that led to the assistant reply (empty for user turns).
|
|
#[derive(Debug, Serialize)]
|
|
pub struct RenderedPersonaMessage {
|
|
pub role: String,
|
|
pub content: String,
|
|
pub is_initial: bool,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub tools: Vec<crate::ai::handlers::HistoryToolInvocation>,
|
|
}
|
|
|
|
/// Render a branch path into the persona wire shape.
|
|
///
|
|
/// A thin wrapper over the file chat's `render_tree_path` so both surfaces
|
|
/// fold tool scaffolding, detect divergences and rank branches identically.
|
|
/// The one persona-specific adjustment is `is_initial`: the file chat's
|
|
/// first rendered message is the synthetic "describe this photo" prompt,
|
|
/// which must never be rewound or regenerated, whereas a persona chat's
|
|
/// first message is the user's own question and both actions are legitimate
|
|
/// on it.
|
|
fn render_persona_path(
|
|
store: &ChatHistoryStore,
|
|
path: &[&StoredChatNode],
|
|
) -> (
|
|
Vec<RenderedPersonaMessage>,
|
|
usize,
|
|
Vec<u64>,
|
|
Vec<Option<ForkInfo>>,
|
|
) {
|
|
let (rendered, turn_count, node_ids, fork_info) = render_tree_path(store, path);
|
|
let rendered = rendered
|
|
.into_iter()
|
|
.map(|m| RenderedPersonaMessage {
|
|
role: m.role,
|
|
content: m.content,
|
|
is_initial: false,
|
|
tools: m
|
|
.tools
|
|
.into_iter()
|
|
.map(|t| crate::ai::handlers::HistoryToolInvocation {
|
|
name: t.name,
|
|
arguments: t.arguments,
|
|
result: t.result,
|
|
result_truncated: t.result_truncated,
|
|
})
|
|
.collect(),
|
|
})
|
|
.collect();
|
|
(rendered, turn_count, node_ids, fork_info)
|
|
}
|
|
|
|
/// The node a rewind should re-anchor `active_leaf_id` on.
|
|
///
|
|
/// `node_ids[i]` is the tree node behind rendered message `i`, so keeping
|
|
/// everything before index `i` means anchoring on `node_ids[i - 1]`. Index 0
|
|
/// discards the whole transcript, which anchors on the parent of the first
|
|
/// rendered node — the seed system node — so that a resend forks there and
|
|
/// the discarded path stays reachable via the chip on the new first bubble.
|
|
fn rewind_target_leaf(
|
|
store: &ChatHistoryStore,
|
|
node_ids: &[u64],
|
|
discard_from_rendered_index: usize,
|
|
) -> Result<u64> {
|
|
if discard_from_rendered_index == 0 {
|
|
let first = *node_ids
|
|
.first()
|
|
.ok_or_else(|| anyhow!("discard_from_rendered_index out of range"))?;
|
|
return store
|
|
.parent_of(first)
|
|
.map(|n| n.id)
|
|
.ok_or_else(|| anyhow!("cannot rewind past the start of the conversation"));
|
|
}
|
|
node_ids
|
|
.get(discard_from_rendered_index - 1)
|
|
.copied()
|
|
.ok_or_else(|| anyhow!("discard_from_rendered_index out of range"))
|
|
}
|
|
|
|
/// Hard cap on a generated conversation title. Long enough to be
|
|
/// descriptive, short enough to fit a nav bar and a list row heading.
|
|
const TITLE_MAX_CHARS: usize = 48;
|
|
|
|
/// Clean up whatever the model returned into a usable title.
|
|
///
|
|
/// Small models are chatty about this task: they wrap titles in quotes,
|
|
/// prefix them with "Title:", tack on a trailing period, or return a whole
|
|
/// sentence. Everything here is defensive against that, and anything still
|
|
/// too long is truncated on a character boundary.
|
|
pub fn sanitize_title(raw: &str) -> String {
|
|
let mut text = raw.trim();
|
|
// Some models answer with a preamble line then the title; take the first
|
|
// non-empty line and drop the rest.
|
|
if let Some(line) = text.lines().map(str::trim).find(|l| !l.is_empty()) {
|
|
text = line;
|
|
}
|
|
let lowered = text.to_lowercase();
|
|
for prefix in ["title:", "conversation title:", "chat title:"] {
|
|
if lowered.starts_with(prefix) {
|
|
text = text[prefix.len()..].trim();
|
|
break;
|
|
}
|
|
}
|
|
let text = text
|
|
.trim_matches(|c: char| c == '"' || c == '\'' || c == '“' || c == '”' || c == '*')
|
|
.trim()
|
|
.trim_end_matches('.')
|
|
.trim();
|
|
let flattened = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if flattened.chars().count() <= TITLE_MAX_CHARS {
|
|
return flattened;
|
|
}
|
|
let truncated: String = flattened.chars().take(TITLE_MAX_CHARS).collect();
|
|
format!("{}…", truncated.trim_end())
|
|
}
|
|
|
|
/// Title to use when the model can't be reached or returns nothing usable.
|
|
///
|
|
/// The user's opening question is a better name than "Untitled": it is
|
|
/// exactly the thing they would scan the list for.
|
|
pub fn fallback_title(messages: &[ChatMessage]) -> String {
|
|
messages
|
|
.iter()
|
|
.find(|m| m.role == "user")
|
|
.map(|m| sanitize_title(&m.content))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The prompt used to name a conversation from its opening exchange.
|
|
pub fn title_prompt(user_message: &str, assistant_reply: &str) -> String {
|
|
format!(
|
|
"Summarize this conversation opening as a short title of at most six \
|
|
words. Reply with the title alone — no quotes, no punctuation at the \
|
|
end, no preamble.\n\nUser: {}\n\nAssistant: {}",
|
|
conversation_snippet(user_message),
|
|
conversation_snippet(assistant_reply),
|
|
)
|
|
}
|
|
|
|
/// Soft cap for the preview line on the chat list. Long enough to tell two
|
|
/// conversations apart, short enough that a row stays two lines on a phone.
|
|
const SNIPPET_MAX_CHARS: usize = 140;
|
|
|
|
/// Collapse a message into a single-line preview for the chat list.
|
|
///
|
|
/// Newlines become spaces so a markdown reply doesn't blow the row height,
|
|
/// and the cut is taken on a character boundary — slicing a UTF-8 string by
|
|
/// byte index panics the moment a reply contains an emoji or an accent.
|
|
pub fn conversation_snippet(content: &str) -> String {
|
|
let flattened = content.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if flattened.chars().count() <= SNIPPET_MAX_CHARS {
|
|
return flattened;
|
|
}
|
|
let truncated: String = flattened.chars().take(SNIPPET_MAX_CHARS).collect();
|
|
format!("{}…", truncated.trim_end())
|
|
}
|
|
|
|
/// Map the internal fork markers onto the serialized wire type shared with
|
|
/// the file chat's history response.
|
|
fn wire_fork_info(
|
|
fork_info: Vec<Option<ForkInfo>>,
|
|
) -> Vec<Option<crate::ai::handlers::ChatForkInfo>> {
|
|
fork_info
|
|
.into_iter()
|
|
.map(|f| {
|
|
f.map(|fi| crate::ai::handlers::ChatForkInfo {
|
|
position: fi.position,
|
|
total: fi.total,
|
|
node_id: fi.node_id,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// One row of the persona chat list: enough to render a conversation card
|
|
/// without shipping the whole transcript.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PersonaConversationSummary {
|
|
pub conversation_id: String,
|
|
pub persona_id: String,
|
|
pub persona_name: String,
|
|
/// Generated name for the conversation. Empty until the first turn
|
|
/// completes; the client falls back to the persona name.
|
|
pub title: String,
|
|
/// Last rendered message on the active branch, flattened to one line.
|
|
pub snippet: String,
|
|
/// Role behind `snippet`, so the list can prefix the user's own last
|
|
/// word with "You:".
|
|
pub snippet_role: String,
|
|
/// Assistant turns on the active branch.
|
|
pub turn_count: u32,
|
|
/// Rendered messages on the active branch (user + assistant bubbles).
|
|
pub message_count: usize,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
/// Whether the conversation has been forked — the list marks these so a
|
|
/// user can tell which transcripts have alternates parked in them.
|
|
pub has_branches: bool,
|
|
}
|
|
|
|
impl PersonaChatHistoryView {
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
conversation_id: String::new(),
|
|
persona_id: String::new(),
|
|
title: String::new(),
|
|
messages: Vec::new(),
|
|
turn_count: 0,
|
|
model_version: String::new(),
|
|
backend: String::new(),
|
|
active_leaf_id: 0,
|
|
viewing_branch_id: 0,
|
|
fork_info: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Per-persona in-flight mutex
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// One async mutex per conversation — serializes concurrent writers to a
|
|
/// single transcript (a turn appending, a rewind re-pointing the leaf) so
|
|
/// the stored tree never interleaves two mutations.
|
|
pub type PersonaLockMap = TokioMutex<HashMap<String, Arc<TokioMutex<()>>>>;
|
|
|
|
/// Tracks the turn in flight for each conversation so a second dispatch
|
|
/// against the same conversation is rejected (HTTP 409) instead of racing
|
|
/// the first. Keyed on `conversation_id` alone — ids are uuids, so they
|
|
/// don't collide across users.
|
|
#[derive(Default)]
|
|
pub struct InFlightPersonaTurns {
|
|
turns: StdMutex<HashMap<String, String>>,
|
|
}
|
|
|
|
impl InFlightPersonaTurns {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Claim `conversation_id` for `turn_id`. Fails with the id of the turn
|
|
/// already holding the slot, which the handler reports so the client can
|
|
/// re-attach to it rather than just seeing a bare conflict.
|
|
pub fn claim(&self, conversation_id: &str, turn_id: &str) -> Result<(), String> {
|
|
let mut map = self.turns.lock().expect("in-flight turns poisoned");
|
|
if let Some(existing) = map.get(conversation_id) {
|
|
return Err(existing.clone());
|
|
}
|
|
map.insert(conversation_id.to_string(), turn_id.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
pub fn release(&self, conversation_id: &str) {
|
|
if let Ok(mut map) = self.turns.lock() {
|
|
map.remove(conversation_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Frees the in-flight slot when the spawned turn task ends — on completion,
|
|
/// error, or abort.
|
|
struct InFlightGuard {
|
|
in_flight: Arc<InFlightPersonaTurns>,
|
|
conversation_id: String,
|
|
}
|
|
|
|
impl Drop for InFlightGuard {
|
|
fn drop(&mut self) {
|
|
self.in_flight.release(&self.conversation_id);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct PersonaChatSession {
|
|
generator: Arc<InsightGenerator>,
|
|
persona_dao: Arc<StdMutex<Box<dyn PersonaDao>>>,
|
|
chat_locks: Arc<PersonaLockMap>,
|
|
in_flight: Arc<InFlightPersonaTurns>,
|
|
}
|
|
|
|
impl PersonaChatSession {
|
|
pub fn new(
|
|
generator: Arc<InsightGenerator>,
|
|
persona_dao: Arc<StdMutex<Box<dyn PersonaDao>>>,
|
|
chat_locks: Arc<PersonaLockMap>,
|
|
) -> Self {
|
|
Self {
|
|
generator,
|
|
persona_dao,
|
|
chat_locks,
|
|
in_flight: Arc::new(InFlightPersonaTurns::new()),
|
|
}
|
|
}
|
|
|
|
/// Start a new conversation with a persona.
|
|
///
|
|
/// Always a fresh row: a persona is expected to hold several separate
|
|
/// conversations, so this never resumes an existing one.
|
|
pub fn create_conversation(
|
|
&self,
|
|
user_id: i32,
|
|
persona_id: &str,
|
|
) -> Result<String, PersonaChatError> {
|
|
let persona_id = persona_id.trim();
|
|
if persona_id.is_empty() {
|
|
return Err(PersonaChatError::UnknownPersona(String::new()));
|
|
}
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
// Validate up front rather than letting the first turn 404: a
|
|
// conversation row pointing at a persona that doesn't exist would be
|
|
// a permanently dead entry in the list.
|
|
let persona = dao
|
|
.get_persona(user_id, persona_id)
|
|
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?;
|
|
if persona.is_none() {
|
|
return Err(PersonaChatError::UnknownPersona(persona_id.to_string()));
|
|
}
|
|
dao.create_persona_chat(user_id, persona_id, Utc::now().timestamp_millis())
|
|
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))
|
|
}
|
|
|
|
/// Delete a conversation and its transcript outright.
|
|
pub fn delete_conversation(&self, user_id: i32, conversation_id: &str) -> Result<()> {
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
dao.delete_persona_chat(user_id, conversation_id)
|
|
.map_err(|e| anyhow!("failed to delete persona chat: {e:?}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Load a transcript branch for one conversation.
|
|
///
|
|
/// `branch_id` renders an alternate leaf without making it active, so
|
|
/// the client can preview a fork before committing to it; `None` renders
|
|
/// the active branch. Returns an empty envelope for a conversation that
|
|
/// has been created but not yet spoken in.
|
|
pub fn load_history(
|
|
&self,
|
|
user_id: i32,
|
|
conversation_id: &str,
|
|
branch_id: Option<u64>,
|
|
) -> Result<PersonaChatHistoryView> {
|
|
let row = {
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
dao.get_persona_chat(user_id, conversation_id)
|
|
.map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?
|
|
};
|
|
let Some(row) = row else {
|
|
bail!("conversation not found");
|
|
};
|
|
let store = decode_store(&row.messages_json)?;
|
|
|
|
let identity = PersonaChatHistoryView {
|
|
conversation_id: row.conversation_id.clone(),
|
|
persona_id: row.persona_id.clone(),
|
|
title: row.title.clone(),
|
|
..PersonaChatHistoryView::empty()
|
|
};
|
|
// A conversation holding only its seed system node renders as the
|
|
// empty state, not as a zero-message transcript with a leaf id.
|
|
if store.nodes.is_empty() {
|
|
return Ok(identity);
|
|
}
|
|
|
|
let target_leaf = branch_id.unwrap_or(store.active_leaf_id);
|
|
let path = store
|
|
.path_to_leaf(target_leaf)
|
|
.ok_or_else(|| anyhow!("branch_id {target_leaf} not found in tree"))?;
|
|
let (messages, turn_count, _node_ids, fork_info) = render_persona_path(&store, &path);
|
|
|
|
Ok(PersonaChatHistoryView {
|
|
messages,
|
|
// Assistant turns actually on this path — not the stored count,
|
|
// which tracks the active branch.
|
|
turn_count: turn_count as u32,
|
|
active_leaf_id: store.active_leaf_id,
|
|
viewing_branch_id: target_leaf,
|
|
fork_info: wire_fork_info(fork_info),
|
|
..identity
|
|
})
|
|
}
|
|
|
|
/// Dispatch a new turn. Returns the `turn_id` immediately; the
|
|
/// streaming agent loop runs in a Tokio task and pushes events to
|
|
/// the registered `TurnEntry`.
|
|
pub async fn dispatch_turn(
|
|
self: Arc<Self>,
|
|
registry: Arc<TurnRegistry>,
|
|
user_id: i32,
|
|
library_id: i32,
|
|
req: PersonaChatTurnRequest,
|
|
) -> Result<String, PersonaChatError> {
|
|
let trimmed = req.user_message.trim().to_string();
|
|
if trimmed.is_empty() {
|
|
return Err(PersonaChatError::EmptyMessage);
|
|
}
|
|
if trimmed.len() > 8192 {
|
|
return Err(PersonaChatError::MessageTooLong);
|
|
}
|
|
let conversation_id = req.conversation_id.trim().to_string();
|
|
if conversation_id.is_empty() {
|
|
return Err(PersonaChatError::UnknownConversation(conversation_id));
|
|
}
|
|
|
|
// The persona comes from the stored conversation, not the request:
|
|
// a transcript's voice is fixed when it is created, so a stale
|
|
// client can't quietly swap it half-way through a thread.
|
|
let (persona, persona_id) = {
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
let row = dao
|
|
.get_persona_chat(user_id, &conversation_id)
|
|
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
|
|
.ok_or_else(|| PersonaChatError::UnknownConversation(conversation_id.clone()))?;
|
|
let persona = dao
|
|
.get_persona(user_id, &row.persona_id)
|
|
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
|
|
.ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))?;
|
|
(persona, row.persona_id)
|
|
};
|
|
|
|
let turn_id = Uuid::new_v4().to_string();
|
|
self.in_flight
|
|
.claim(&conversation_id, &turn_id)
|
|
.map_err(|_| PersonaChatError::ConcurrentTurn)?;
|
|
|
|
let entry = Arc::new(TurnEntry::new_persona(
|
|
turn_id.clone(),
|
|
persona_id.clone(),
|
|
library_id,
|
|
));
|
|
registry.insert(entry.clone()).await;
|
|
|
|
let svc = self.clone();
|
|
let persona_for_task = persona.clone();
|
|
let entry_clone = entry.clone();
|
|
let req_for_task = PersonaChatTurnRequest {
|
|
user_message: trimmed.clone(),
|
|
..req
|
|
};
|
|
let in_flight_guard = InFlightGuard {
|
|
in_flight: self.in_flight.clone(),
|
|
conversation_id: conversation_id.clone(),
|
|
};
|
|
// Created before the spawn so it parents on the dispatching request,
|
|
// then carried into the task with `.instrument` (tracing context is
|
|
// thread-local and does not cross `tokio::spawn` on its own).
|
|
let turn_span = tracing::info_span!(
|
|
"ai.persona_chat.turn.execute",
|
|
turn_id = %turn_id,
|
|
persona_id = %persona_id,
|
|
conversation_id = %conversation_id,
|
|
library_id = library_id,
|
|
);
|
|
let handle = tokio::spawn(
|
|
async move {
|
|
let span = tracing::Span::current();
|
|
let result = svc
|
|
.run_streaming_turn(
|
|
persona_for_task,
|
|
req_for_task,
|
|
entry_clone.clone(),
|
|
user_id,
|
|
library_id,
|
|
&span,
|
|
)
|
|
.await;
|
|
match result {
|
|
Ok(()) => {
|
|
span.set_attribute("status", "done");
|
|
span.set_status(Status::Ok);
|
|
}
|
|
Err(e) => {
|
|
span.set_attribute("status", "error");
|
|
span.set_status(Status::error(format!("{e}")));
|
|
let _ = entry_clone
|
|
.push_event(ChatStreamEvent::Error(format!("{e}")))
|
|
.await;
|
|
}
|
|
}
|
|
entry_clone.set_terminal_status(crate::ai::turn_registry::TurnStatus::Done);
|
|
// Frees the in-flight slot. Placed explicitly so the guard is
|
|
// captured by the spawned task: it must drop when the task ends
|
|
// (completion, error, or abort) — not when dispatch returns.
|
|
drop(in_flight_guard);
|
|
}
|
|
.instrument(turn_span),
|
|
);
|
|
|
|
entry.set_abort_handle(handle.abort_handle());
|
|
Ok(turn_id)
|
|
}
|
|
|
|
/// The streaming turn body. Loads the persona, builds the messages
|
|
/// vector (system + persisted history + new user turn), runs the
|
|
/// shared agent loop, persists the updated transcript.
|
|
async fn run_streaming_turn(
|
|
self: Arc<Self>,
|
|
persona: crate::database::models::Persona,
|
|
req: PersonaChatTurnRequest,
|
|
entry: Arc<TurnEntry>,
|
|
user_id: i32,
|
|
_library_id: i32,
|
|
turn_span: &tracing::Span,
|
|
) -> Result<()> {
|
|
// Per-conversation mutex — serializes this turn against a
|
|
// concurrent rewind or branch switch on the same transcript.
|
|
let conversation_id = req.conversation_id.trim().to_string();
|
|
let lock = {
|
|
let mut locks = self.chat_locks.lock().await;
|
|
locks
|
|
.entry(conversation_id.clone())
|
|
.or_insert_with(|| Arc::new(TokioMutex::new(())))
|
|
.clone()
|
|
};
|
|
let _guard = lock.lock().await;
|
|
|
|
// Replay the active branch of the conversation tree.
|
|
let row = {
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
dao.get_persona_chat(user_id, &conversation_id)
|
|
.map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?
|
|
};
|
|
let row = row.ok_or_else(|| anyhow!("conversation not found"))?;
|
|
let needs_title = row.title.trim().is_empty();
|
|
|
|
let system_prompt = resolve_persona_system_prompt(Some(&persona))
|
|
.unwrap_or_else(|| "You are a helpful assistant.".to_string());
|
|
|
|
let stored = Some(decode_store(&row.messages_json)?);
|
|
// `seeded` marks a conversation whose messages are NOT yet backed by
|
|
// tree nodes, so the persistence step below knows to write the seed
|
|
// out along with the turn.
|
|
let (mut store, mut messages, seeded) = match stored {
|
|
Some(store) if !store.nodes.is_empty() => {
|
|
let mut messages: Vec<ChatMessage> = {
|
|
let path = store.path_to_leaf(store.active_leaf_id).ok_or_else(|| {
|
|
anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id)
|
|
})?;
|
|
path.iter().map(|n| n.message.clone()).collect()
|
|
};
|
|
// Re-resolve the system prompt from the persona on every turn.
|
|
// The stored node stays as the historical record of what the
|
|
// model saw; editing a persona has to take effect on
|
|
// conversations already in flight, which it would not if we
|
|
// replayed the copy seeded at conversation start forever.
|
|
if let Some(sys) = messages.first_mut()
|
|
&& sys.role == "system"
|
|
{
|
|
sys.content = system_prompt.clone();
|
|
}
|
|
(store, messages, false)
|
|
}
|
|
_ => (
|
|
ChatHistoryStore {
|
|
nodes: Vec::new(),
|
|
active_leaf_id: 0,
|
|
},
|
|
seed_messages_for_persona(&system_prompt),
|
|
true,
|
|
),
|
|
};
|
|
|
|
// Backend selection. Defaults to local; explicit `backend` wins.
|
|
let backend_kind = match req
|
|
.backend
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_lowercase)
|
|
.as_deref()
|
|
{
|
|
Some("hybrid") => BackendKind::Hybrid,
|
|
_ => BackendKind::Local,
|
|
};
|
|
let max_iterations = DEFAULT_MAX_ITERATIONS.clamp(1, env_max_iterations());
|
|
let overrides = SamplingOverrides {
|
|
model: req.model.clone().filter(|m| !m.is_empty()),
|
|
num_ctx: req.num_ctx,
|
|
temperature: req.temperature,
|
|
top_p: req.top_p,
|
|
top_k: req.top_k,
|
|
min_p: req.min_p,
|
|
enable_thinking: req.enable_thinking,
|
|
};
|
|
let backend = self
|
|
.generator
|
|
.resolve_backend(backend_kind, &overrides)
|
|
.await?;
|
|
let model_used = backend.model().to_string();
|
|
// llm.* attributes on the turn-level span (issue #130). The child
|
|
// llm.request spans parent on it ambiently (the task is instrumented).
|
|
turn_span.set_attribute("llm.backend", backend.kind.as_str());
|
|
turn_span.set_attribute("llm.model", model_used.clone());
|
|
|
|
// Build a no-photo tool catalog. `current_gate_opts_for_persona`
|
|
// already probes each tool's backing table for presence and
|
|
// honours the persona's `allow_agent_corrections` toggle.
|
|
let gate_opts = self.generator.current_gate_opts_for_persona(
|
|
backend.images_inline,
|
|
Some((user_id, &persona.persona_id)),
|
|
);
|
|
let tools = InsightGenerator::build_tool_definitions(gate_opts);
|
|
|
|
// Append the new user turn + apply the per-turn system-prompt
|
|
// override. Per-persona overrides are NOT persisted (the persona
|
|
// voice survives a voice-flipped turn); applied ephemerally.
|
|
let override_text = req.system_prompt.as_deref();
|
|
let prompt_override = if let Some(s) = override_text {
|
|
let t = s.trim();
|
|
if t.is_empty() {
|
|
None
|
|
} else {
|
|
Some(t.to_string())
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Trim to the model's context window before the turn. Without this a
|
|
// rolling persona transcript grows unbounded until the backend
|
|
// silently drops the front of it mid-conversation.
|
|
let budget_tokens = (req.num_ctx.unwrap_or_else(env_default_num_ctx) as usize)
|
|
.saturating_sub(RESPONSE_HEADROOM_TOKENS);
|
|
let budget_bytes = budget_tokens.saturating_mul(BYTES_PER_TOKEN);
|
|
let truncated = apply_context_budget(&mut messages, budget_bytes);
|
|
if truncated {
|
|
let _ = entry.push_event(ChatStreamEvent::Truncated).await;
|
|
}
|
|
|
|
// How much of `messages` is already backed by tree nodes. Read after
|
|
// the budget pass, because truncation drains replayed history out of
|
|
// the middle — taking the path length instead would misalign the
|
|
// slice below and re-append old turns as new nodes.
|
|
let in_tree_len = if seeded { 0 } else { messages.len() };
|
|
|
|
messages.push(ChatMessage::user(req.user_message.clone()));
|
|
if let Some(ref note) = prompt_override {
|
|
// Suffix the system prompt with the override for this turn only.
|
|
// Restored to the persisted value below before serialization.
|
|
if let Some(sys) = messages.iter_mut().find(|m| m.role == "system") {
|
|
sys.content = format!("{}\n\n{}", sys.content, note);
|
|
}
|
|
}
|
|
|
|
// Run the shared agent loop. `image_base64 = None` and `normalized = ""`
|
|
// because there's no photo — `execute_tool` is gated by tool name and
|
|
// returns "tool not found" for any file-specific tool the LLM might
|
|
// hallucinate.
|
|
let outcome = crate::ai::insight_chat::run_streaming_agentic_loop_with_entry(
|
|
&self.generator,
|
|
&backend,
|
|
&mut messages,
|
|
tools,
|
|
&None,
|
|
"",
|
|
user_id,
|
|
&persona.persona_id,
|
|
max_iterations,
|
|
&entry,
|
|
)
|
|
.await
|
|
.map_err(|e| anyhow!("persona chat loop failed: {e:?}"))?;
|
|
|
|
// Cancelled mid-flight: the DELETE handler has already pushed the
|
|
// terminal frame and flipped the registry status. Persisting here
|
|
// would commit a half-finished turn and emit a second terminal frame.
|
|
if outcome.cancelled {
|
|
return Ok(());
|
|
}
|
|
|
|
// Restore the persisted system prompt before serializing (the
|
|
// override was for this turn only).
|
|
if let Some(ref note) = prompt_override
|
|
&& let Some(sys) = messages.iter_mut().find(|m| m.role == "system")
|
|
{
|
|
let trimmed = format!("\n\n{}", note);
|
|
if sys.content.ends_with(&trimmed) {
|
|
sys.content.truncate(sys.content.len() - trimmed.len());
|
|
}
|
|
}
|
|
|
|
// Append this turn as a chain of nodes hanging off the branch we
|
|
// replayed. Rewind and fork work by re-pointing `active_leaf_id` at
|
|
// an earlier node, so a discarded path stays reachable instead of
|
|
// being overwritten.
|
|
let mut parent_id = if seeded {
|
|
None
|
|
} else {
|
|
Some(store.active_leaf_id)
|
|
};
|
|
for msg in &messages[in_tree_len..] {
|
|
parent_id = Some(store.append_node(parent_id, msg.clone()));
|
|
}
|
|
if let Some(last_id) = parent_id {
|
|
store.active_leaf_id = last_id;
|
|
}
|
|
|
|
let json = encode_store(&store)?;
|
|
// Assistant turns on the new active branch. Cumulative, unlike the
|
|
// per-write delta this column used to hold, so the list screen can
|
|
// show how long a conversation actually is.
|
|
let turn_count = store
|
|
.path_to_leaf(store.active_leaf_id)
|
|
.map(|path| render_persona_path(&store, &path).1)
|
|
.unwrap_or(0) as i32;
|
|
{
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
let rows = dao
|
|
.update_persona_chat(
|
|
user_id,
|
|
&conversation_id,
|
|
&json,
|
|
turn_count,
|
|
Utc::now().timestamp_millis(),
|
|
)
|
|
.map_err(|e| anyhow!("failed to persist persona chat: {e:?}"))?;
|
|
if rows == 0 {
|
|
bail!("conversation not found");
|
|
}
|
|
}
|
|
|
|
// Name the conversation off its opening exchange. Best-effort and
|
|
// after persistence: a failed title must not lose the turn, and the
|
|
// list falls back to the persona name while the title is empty.
|
|
if needs_title {
|
|
let title = self
|
|
.generate_title(&backend, &messages)
|
|
.await
|
|
.unwrap_or_else(|| fallback_title(&messages));
|
|
if !title.is_empty() {
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
if let Err(e) = dao.set_persona_chat_title(user_id, &conversation_id, &title) {
|
|
log::warn!("failed to store persona chat title: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
let _ = entry
|
|
.push_event(ChatStreamEvent::Done {
|
|
tool_calls_made: outcome.tool_calls_made,
|
|
iterations_used: outcome.iterations_used,
|
|
truncated,
|
|
prompt_tokens: outcome.last_prompt_eval_count,
|
|
eval_tokens: outcome.last_eval_count,
|
|
num_ctx: req.num_ctx,
|
|
amended_insight_id: None,
|
|
backend_used: backend_kind.as_str().to_string(),
|
|
model_used,
|
|
cancelled: outcome.cancelled,
|
|
})
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Every conversation this user has going, newest first.
|
|
///
|
|
/// Conversations whose persona has since been deleted are omitted: they
|
|
/// cannot be resumed (dispatching a turn against an unknown persona is a
|
|
/// 404), so listing them would only offer a dead-end tap target.
|
|
/// Conversations that hold nothing but the seed are omitted too — the
|
|
/// user has never actually said anything in them.
|
|
pub fn list_conversations(&self, user_id: i32) -> Result<Vec<PersonaConversationSummary>> {
|
|
let (rows, personas) = {
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
let rows = dao
|
|
.list_persona_chats(user_id)
|
|
.map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?;
|
|
let personas = dao
|
|
.list_personas(user_id)
|
|
.map_err(|e| anyhow!("failed to list personas: {e:?}"))?;
|
|
(rows, personas)
|
|
};
|
|
let names: HashMap<String, String> = personas
|
|
.into_iter()
|
|
.map(|p| (p.persona_id, p.name))
|
|
.collect();
|
|
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for entry in rows {
|
|
let Some(persona_name) = names.get(&entry.persona_id).cloned() else {
|
|
continue;
|
|
};
|
|
// A single unreadable transcript shouldn't take the whole list
|
|
// down with it — skip the row and keep going.
|
|
let Ok(store) = decode_store(&entry.messages_json) else {
|
|
log::warn!(
|
|
"skipping unreadable persona chat transcript {}",
|
|
entry.conversation_id
|
|
);
|
|
continue;
|
|
};
|
|
// An empty conversation still gets a row: the user created it and
|
|
// needs somewhere to tap back into. It just has no preview yet.
|
|
let rendered = store
|
|
.path_to_leaf(store.active_leaf_id)
|
|
.map(|path| render_persona_path(&store, &path));
|
|
let (messages, turn_count) = match rendered {
|
|
Some((messages, turn_count, _ids, _forks)) => (messages, turn_count),
|
|
None => (Vec::new(), 0),
|
|
};
|
|
let last = messages.last();
|
|
|
|
out.push(PersonaConversationSummary {
|
|
conversation_id: entry.conversation_id,
|
|
persona_id: entry.persona_id,
|
|
persona_name,
|
|
title: entry.title,
|
|
snippet: last
|
|
.map(|m| conversation_snippet(&m.content))
|
|
.unwrap_or_default(),
|
|
snippet_role: last.map(|m| m.role.clone()).unwrap_or_default(),
|
|
turn_count: turn_count as u32,
|
|
message_count: messages.len(),
|
|
created_at: entry.created_at,
|
|
updated_at: entry.updated_at,
|
|
has_branches: store.leaves().len() > 1,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Ask the model to name a conversation from its opening exchange.
|
|
///
|
|
/// Returns `None` on any failure — a title is a nicety, and the caller
|
|
/// falls back to the user's first message. Uses the same backend the
|
|
/// turn ran on so a local-only setup never reaches the network.
|
|
async fn generate_title(
|
|
&self,
|
|
backend: &crate::ai::backend::ResolvedBackend,
|
|
messages: &[ChatMessage],
|
|
) -> Option<String> {
|
|
let user_message = messages.iter().find(|m| m.role == "user")?;
|
|
// The last assistant message with actual prose — earlier ones may be
|
|
// empty tool-dispatch scaffolding.
|
|
let reply = messages
|
|
.iter()
|
|
.rev()
|
|
.find(|m| m.role == "assistant" && !m.content.trim().is_empty())?;
|
|
|
|
let prompt = title_prompt(&user_message.content, &reply.content);
|
|
match backend.chat().generate(&prompt, None, None).await {
|
|
Ok(raw) => {
|
|
let title = sanitize_title(&crate::ai::llm_client::strip_think_blocks(&raw));
|
|
if title.is_empty() { None } else { Some(title) }
|
|
}
|
|
Err(e) => {
|
|
log::warn!("persona chat title generation failed: {e:?}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load the tree, mutate it under the per-conversation lock, and persist.
|
|
///
|
|
/// Every branch operation is the same three steps around a different
|
|
/// mutation, and all of them have to serialize against an in-flight turn
|
|
/// (which appends to `active_leaf_id`) or the two writers clobber each
|
|
/// other's copy of the transcript.
|
|
async fn mutate_store<F>(&self, user_id: i32, conversation_id: &str, mutate: F) -> Result<()>
|
|
where
|
|
F: FnOnce(&mut ChatHistoryStore) -> Result<()>,
|
|
{
|
|
let lock = {
|
|
let mut locks = self.chat_locks.lock().await;
|
|
locks
|
|
.entry(conversation_id.to_string())
|
|
.or_insert_with(|| Arc::new(TokioMutex::new(())))
|
|
.clone()
|
|
};
|
|
let _guard = lock.lock().await;
|
|
|
|
let row = {
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
dao.get_persona_chat(user_id, conversation_id)
|
|
.map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?
|
|
};
|
|
let row = row.ok_or_else(|| anyhow!("conversation not found"))?;
|
|
let mut store = decode_store(&row.messages_json)?;
|
|
if store.nodes.is_empty() {
|
|
bail!("no chat history for this conversation");
|
|
}
|
|
|
|
mutate(&mut store)?;
|
|
|
|
let json = encode_store(&store)?;
|
|
let turn_count = store
|
|
.path_to_leaf(store.active_leaf_id)
|
|
.map(|path| render_persona_path(&store, &path).1)
|
|
.unwrap_or(0) as i32;
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
let rows = dao
|
|
.update_persona_chat(
|
|
user_id,
|
|
conversation_id,
|
|
&json,
|
|
turn_count,
|
|
Utc::now().timestamp_millis(),
|
|
)
|
|
.map_err(|e| anyhow!("failed to persist persona chat: {e:?}"))?;
|
|
if rows == 0 {
|
|
bail!("conversation not found");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Rewind so the rendered message at `discard_from_rendered_index` — and
|
|
/// everything after it — leaves the active path.
|
|
///
|
|
/// Nothing is deleted: the active leaf is re-pointed at the last kept
|
|
/// node, and the discarded path survives as a fork the user can switch
|
|
/// back to. Unlike the file chat, index 0 is rewindable: its index 0 is a
|
|
/// synthetic "describe this photo" prompt, whereas here it is the user's
|
|
/// own first question and editing it is a reasonable thing to want.
|
|
pub async fn rewind(
|
|
&self,
|
|
user_id: i32,
|
|
conversation_id: &str,
|
|
discard_from_rendered_index: usize,
|
|
) -> Result<()> {
|
|
self.mutate_store(user_id, conversation_id, |store| {
|
|
let path = store
|
|
.path_to_leaf(store.active_leaf_id)
|
|
.ok_or_else(|| anyhow!("active_leaf_id not found in tree"))?;
|
|
let (_rendered, _turns, node_ids, _forks) = render_persona_path(store, &path);
|
|
|
|
store.active_leaf_id =
|
|
rewind_target_leaf(store, &node_ids, discard_from_rendered_index)?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Make `leaf_id` the active branch. The path being left behind becomes
|
|
/// a regular fork rather than being discarded.
|
|
pub async fn switch_branch(
|
|
&self,
|
|
user_id: i32,
|
|
conversation_id: &str,
|
|
leaf_id: u64,
|
|
) -> Result<()> {
|
|
self.mutate_store(user_id, conversation_id, |store| {
|
|
if !store.nodes.iter().any(|n| n.id == leaf_id) {
|
|
bail!("branch_id {leaf_id} not found in tree");
|
|
}
|
|
if !store.children_of(leaf_id).is_empty() {
|
|
bail!("branch_id {leaf_id} is not a leaf node");
|
|
}
|
|
store.active_leaf_id = leaf_id;
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// List the branches of a persona's conversation tree.
|
|
///
|
|
/// With `node_id` set (from a rendered message's `fork_info.node_id`)
|
|
/// the list is the position-ranked siblings at that one divergence;
|
|
/// without it, every leaf in the tree.
|
|
pub fn get_branches(
|
|
&self,
|
|
user_id: i32,
|
|
conversation_id: &str,
|
|
node_id: Option<u64>,
|
|
viewing_leaf: Option<u64>,
|
|
) -> Result<(Vec<crate::ai::llm_client::BranchLeafInfo>, u64)> {
|
|
let row = {
|
|
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
|
|
dao.get_persona_chat(user_id, conversation_id)
|
|
.map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?
|
|
};
|
|
let row = row.ok_or_else(|| anyhow!("conversation not found"))?;
|
|
let store = decode_store(&row.messages_json)?;
|
|
if store.nodes.is_empty() {
|
|
bail!("no chat history for this conversation");
|
|
}
|
|
|
|
let list = match node_id {
|
|
Some(fork_node) => {
|
|
if !store.nodes.iter().any(|n| n.id == fork_node) {
|
|
bail!("node_id {fork_node} not found in tree");
|
|
}
|
|
store.branch_options_at(fork_node, viewing_leaf.unwrap_or(store.active_leaf_id))
|
|
}
|
|
None => store.leaves_with_info(),
|
|
};
|
|
Ok((list, store.active_leaf_id))
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// HTTP handlers — /persona_chat/*
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// Query params for GET /persona_chat/history. `library` is accepted for
|
|
/// client parity with the file chat but does not scope persistence — a
|
|
/// persona transcript is keyed on `(user_id, persona_id)` only.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonaChatHistoryQuery {
|
|
pub conversation_id: String,
|
|
#[serde(default)]
|
|
#[allow(dead_code)]
|
|
pub library: Option<String>,
|
|
/// Render the branch anchored at this leaf instead of the active one,
|
|
/// so the client can preview a fork without committing to it.
|
|
#[serde(default)]
|
|
pub branch_id: Option<u64>,
|
|
}
|
|
|
|
/// Body for POST /persona_chat/conversations — start a new conversation.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonaChatCreateRequest {
|
|
pub persona_id: String,
|
|
}
|
|
|
|
/// GET /persona_chat/history?persona_id=... — the rendered rolling
|
|
/// transcript. A persona that has never been chatted returns the empty
|
|
/// envelope (200), so the client can render its empty state.
|
|
#[get("/persona_chat/history")]
|
|
pub async fn persona_chat_history_handler(
|
|
_claims: Claims,
|
|
query: web::Query<PersonaChatHistoryQuery>,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
let user_id = _claims.sub.parse::<i32>().unwrap_or(1);
|
|
match app_state.persona_chat_session.load_history(
|
|
user_id,
|
|
&query.conversation_id,
|
|
query.branch_id,
|
|
) {
|
|
Ok(view) => HttpResponse::Ok().json(view),
|
|
Err(e) => {
|
|
let msg = format!("{e}");
|
|
if msg.contains("not found") {
|
|
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
|
} else {
|
|
log::error!("persona chat history load failed: {msg}");
|
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// POST /persona_chat/turn — dispatch an async turn. Returns 202 with
|
|
/// the `turn_id` immediately; the streaming agent loop runs in a
|
|
/// background task and the client opens the SSE replay stream with it.
|
|
#[post("/persona_chat/turn")]
|
|
#[tracing::instrument(name = "http.persona_chat.turn", skip_all)]
|
|
pub async fn persona_chat_turn_handler(
|
|
claims: Claims,
|
|
request: web::Json<PersonaChatTurnRequest>,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
let span = tracing::Span::current();
|
|
span.set_attribute("conversation_id", request.conversation_id.clone());
|
|
|
|
// The transcript is not library-scoped, but the turn's tool catalog
|
|
// (memories, SMS, places) resolves against a library — mirror the
|
|
// file chat's resolution so a `?library=` pick steers the tools.
|
|
let library =
|
|
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
|
Ok(Some(lib)) => lib,
|
|
Ok(None) => app_state.primary_library(),
|
|
Err(e) => {
|
|
return HttpResponse::BadRequest().json(serde_json::json!({
|
|
"error": format!("invalid library: {e}")
|
|
}));
|
|
}
|
|
};
|
|
|
|
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
|
let session = app_state.persona_chat_session.clone();
|
|
let registry = app_state.turn_registry.clone();
|
|
|
|
match session
|
|
.dispatch_turn(registry, user_id, library.id, request.into_inner())
|
|
.await
|
|
{
|
|
Ok(turn_id) => {
|
|
span.set_attribute("turn_id", turn_id.clone());
|
|
span.set_status(Status::Ok);
|
|
HttpResponse::Accepted().json(serde_json::json!({
|
|
"turn_id": turn_id,
|
|
"status": "running"
|
|
}))
|
|
}
|
|
Err(e) => {
|
|
span.set_status(Status::error(format!("{e}")));
|
|
match &e {
|
|
PersonaChatError::UnknownPersona(_) | PersonaChatError::UnknownConversation(_) => {
|
|
HttpResponse::NotFound()
|
|
}
|
|
PersonaChatError::EmptyMessage | PersonaChatError::MessageTooLong => {
|
|
HttpResponse::BadRequest()
|
|
}
|
|
PersonaChatError::ConcurrentTurn => HttpResponse::Conflict(),
|
|
PersonaChatError::Db(_) => {
|
|
log::error!("persona chat dispatch failed: {e}");
|
|
HttpResponse::InternalServerError()
|
|
}
|
|
}
|
|
.json(serde_json::json!({ "error": format!("{e}") }))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// POST /persona_chat/conversations — start a new conversation with a
|
|
/// persona. Always creates a fresh transcript, so a persona can hold several
|
|
/// separate threads.
|
|
#[post("/persona_chat/conversations")]
|
|
pub async fn persona_chat_create_conversation_handler(
|
|
claims: Claims,
|
|
request: web::Json<PersonaChatCreateRequest>,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
|
match app_state
|
|
.persona_chat_session
|
|
.create_conversation(user_id, &request.persona_id)
|
|
{
|
|
Ok(conversation_id) => HttpResponse::Created().json(serde_json::json!({
|
|
"conversation_id": conversation_id,
|
|
})),
|
|
Err(e) => match &e {
|
|
PersonaChatError::UnknownPersona(_) => {
|
|
HttpResponse::NotFound().json(serde_json::json!({ "error": format!("{e}") }))
|
|
}
|
|
_ => {
|
|
log::error!("persona chat create failed: {e}");
|
|
HttpResponse::InternalServerError()
|
|
.json(serde_json::json!({ "error": format!("{e}") }))
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// DELETE /persona_chat/conversations/{conversation_id} — remove a
|
|
/// conversation and its transcript.
|
|
#[delete("/persona_chat/conversations/{conversation_id}")]
|
|
pub async fn persona_chat_delete_conversation_handler(
|
|
claims: Claims,
|
|
path: web::Path<String>,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
|
match app_state
|
|
.persona_chat_session
|
|
.delete_conversation(user_id, &path.into_inner())
|
|
{
|
|
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "deleted": true })),
|
|
Err(e) => {
|
|
log::error!("persona chat delete failed: {e}");
|
|
HttpResponse::InternalServerError().json(serde_json::json!({
|
|
"error": format!("{e}")
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// GET /persona_chat/conversations — every conversation this user has
|
|
/// going, newest first. Backs the chat list screen; the detail screen loads
|
|
/// a transcript from `/persona_chat/history`.
|
|
#[get("/persona_chat/conversations")]
|
|
pub async fn persona_chat_conversations_handler(
|
|
claims: Claims,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
|
match app_state.persona_chat_session.list_conversations(user_id) {
|
|
Ok(conversations) => HttpResponse::Ok().json(serde_json::json!({
|
|
"conversations": conversations,
|
|
})),
|
|
Err(e) => {
|
|
log::error!("persona chat conversation list failed: {e}");
|
|
HttpResponse::InternalServerError().json(serde_json::json!({
|
|
"error": format!("{e}")
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Body for POST /persona_chat/rewind.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonaChatRewindRequest {
|
|
pub conversation_id: String,
|
|
/// 0-based index into the rendered transcript. This message and
|
|
/// everything after it leaves the active path (and stays reachable as a
|
|
/// fork). Unlike the file chat, 0 is a legal index here.
|
|
pub discard_from_rendered_index: usize,
|
|
}
|
|
|
|
/// Body for POST /persona_chat/switch-branch.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonaChatSwitchBranchRequest {
|
|
pub conversation_id: String,
|
|
/// Leaf node to make active. Must be an existing leaf in the tree.
|
|
pub branch_id: u64,
|
|
}
|
|
|
|
/// Query params for GET /persona_chat/branches.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonaChatBranchesQuery {
|
|
pub conversation_id: String,
|
|
/// From a rendered message's `fork_info.node_id` — scopes the list to
|
|
/// the siblings at that one divergence instead of every leaf.
|
|
#[serde(default)]
|
|
pub node_id: Option<u64>,
|
|
/// The leaf the client is showing, so scoped options in the same subtree
|
|
/// anchor to it and the client can spot "the branch I am on".
|
|
#[serde(default)]
|
|
pub viewing_branch_id: Option<u64>,
|
|
}
|
|
|
|
/// Map a branch-operation error onto the same status codes the file chat's
|
|
/// equivalents return, so the client can share its error handling.
|
|
fn branch_error_response(context: &str, e: &anyhow::Error) -> HttpResponse {
|
|
let msg = format!("{e}");
|
|
log::error!("{context}: {msg}");
|
|
if msg.contains("conversation not found") {
|
|
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
|
} else if msg.contains("no chat history") {
|
|
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
|
} else if msg.contains("not found")
|
|
|| msg.contains("not a leaf")
|
|
|| msg.contains("out of range")
|
|
|| msg.contains("cannot rewind past")
|
|
{
|
|
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
|
} else {
|
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
|
}
|
|
}
|
|
|
|
/// POST /persona_chat/rewind — move the tail of the conversation off the
|
|
/// active path. Nothing is deleted; the discarded path stays reachable as a
|
|
/// fork, which is what makes "edit & resend" non-destructive.
|
|
#[post("/persona_chat/rewind")]
|
|
pub async fn persona_chat_rewind_handler(
|
|
claims: Claims,
|
|
request: web::Json<PersonaChatRewindRequest>,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
|
match app_state
|
|
.persona_chat_session
|
|
.rewind(
|
|
user_id,
|
|
&request.conversation_id,
|
|
request.discard_from_rendered_index,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "success": true })),
|
|
Err(e) => branch_error_response("persona chat rewind failed", &e),
|
|
}
|
|
}
|
|
|
|
/// POST /persona_chat/switch-branch — make an alternate path the active one.
|
|
#[post("/persona_chat/switch-branch")]
|
|
pub async fn persona_chat_switch_branch_handler(
|
|
claims: Claims,
|
|
request: web::Json<PersonaChatSwitchBranchRequest>,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
|
match app_state
|
|
.persona_chat_session
|
|
.switch_branch(user_id, &request.conversation_id, request.branch_id)
|
|
.await
|
|
{
|
|
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "success": true })),
|
|
Err(e) => branch_error_response("persona chat branch switch failed", &e),
|
|
}
|
|
}
|
|
|
|
/// GET /persona_chat/branches — list the alternate paths of a persona's
|
|
/// conversation. Pair an entry's `id` with the history endpoint's
|
|
/// `branch_id` to preview it, or with switch-branch to adopt it.
|
|
#[get("/persona_chat/branches")]
|
|
pub async fn persona_chat_branches_handler(
|
|
claims: Claims,
|
|
query: web::Query<PersonaChatBranchesQuery>,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
|
match app_state.persona_chat_session.get_branches(
|
|
user_id,
|
|
&query.conversation_id,
|
|
query.node_id,
|
|
query.viewing_branch_id,
|
|
) {
|
|
Ok((branches, active_leaf_id)) => HttpResponse::Ok().json(serde_json::json!({
|
|
"branches": branches,
|
|
"active_leaf_id": active_leaf_id,
|
|
})),
|
|
Err(e) => branch_error_response("persona chat branch list failed", &e),
|
|
}
|
|
}
|
|
|
|
/// GET /persona_chat/turn/{turn_id} — SSE replay for a persona turn.
|
|
/// Delegates to the file chat's replay handler: the registry is keyed
|
|
/// on `turn_id` only, and the `turn_info` frame already carries
|
|
/// `scope: "persona"` + `persona_id` (and no `file_path`) via the
|
|
/// shared renderer.
|
|
#[get("/persona_chat/turn/{turn_id}")]
|
|
pub async fn persona_turn_replay_handler(
|
|
path: web::Path<String>,
|
|
query: web::Query<ReplayQuery>,
|
|
app_state: web::Data<AppState>,
|
|
) -> HttpResponse {
|
|
crate::ai::handlers::turn_replay_impl(path, query, app_state).await
|
|
}
|
|
|
|
/// DELETE /persona_chat/turn/{turn_id} — cancel a running persona turn.
|
|
#[delete("/persona_chat/turn/{turn_id}")]
|
|
pub async fn persona_turn_cancel_handler(
|
|
path: web::Path<String>,
|
|
app_state: web::Data<AppState>,
|
|
) -> impl Responder {
|
|
crate::ai::handlers::cancel_turn_impl(path, app_state).await
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Unit tests — pure helpers only. DAO + handler tests live in their
|
|
// respective test modules (`persona_chat_dao.rs` and `handlers.rs`).
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn persona_with_prompt(p: &str) -> crate::database::models::Persona {
|
|
crate::database::models::Persona {
|
|
id: 0,
|
|
user_id: 1,
|
|
persona_id: "test".to_string(),
|
|
name: "Tester".to_string(),
|
|
system_prompt: p.to_string(),
|
|
is_built_in: false,
|
|
include_all_memories: false,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
reviewed_only_facts: false,
|
|
allow_agent_corrections: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_persona_system_prompt_returns_stored_prompt() {
|
|
let p = persona_with_prompt("You are X.");
|
|
assert_eq!(
|
|
resolve_persona_system_prompt(Some(&p)).as_deref(),
|
|
Some("You are X.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_persona_system_prompt_falls_back_to_none_when_empty() {
|
|
let p = persona_with_prompt("");
|
|
assert!(resolve_persona_system_prompt(Some(&p)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_persona_system_prompt_handles_missing_persona() {
|
|
assert!(resolve_persona_system_prompt(None).is_none());
|
|
}
|
|
|
|
fn assistant_msg(content: &str) -> ChatMessage {
|
|
ChatMessage {
|
|
role: "assistant".to_string(),
|
|
content: content.to_string(),
|
|
tool_calls: None,
|
|
images: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn seed_messages_for_persona_is_system_only() {
|
|
// No synthetic first user turn (there is no photo to anchor on) and
|
|
// no greeting (v1 shipped one, but it never rendered).
|
|
let msgs = seed_messages_for_persona("Be terse.");
|
|
assert_eq!(msgs.len(), 1);
|
|
assert_eq!(msgs[0].role, "system");
|
|
assert_eq!(msgs[0].content, "Be terse.");
|
|
}
|
|
|
|
fn greeting(name: &str) -> ChatMessage {
|
|
assistant_msg(&format!(
|
|
"Hi — I'm {name} ready to help. Ask anything your tools can reach \
|
|
(memories, files, SMS, calendar, places)."
|
|
))
|
|
}
|
|
|
|
#[test]
|
|
fn decode_store_upgrades_a_flat_transcript_into_a_linear_tree() {
|
|
let flat = vec![
|
|
ChatMessage::system("sys".to_string()),
|
|
ChatMessage::user("hi".to_string()),
|
|
assistant_msg("hello"),
|
|
];
|
|
let store = decode_store(&serde_json::to_string(&flat).unwrap()).unwrap();
|
|
assert_eq!(store.nodes.len(), 3);
|
|
assert_eq!(store.nodes[0].parent_id, None);
|
|
assert_eq!(store.nodes[1].parent_id, Some(store.nodes[0].id));
|
|
assert_eq!(store.nodes[2].parent_id, Some(store.nodes[1].id));
|
|
assert_eq!(store.active_leaf_id, store.nodes[2].id);
|
|
}
|
|
|
|
#[test]
|
|
fn decode_store_drops_the_v1_seed_greeting_on_upgrade() {
|
|
// Pre-migration rows carry an assistant greeting the flat renderer
|
|
// hid. The tree renderer renders every assistant node, so the
|
|
// upgrade has to drop it or the user gains a bubble they never saw.
|
|
let flat = vec![
|
|
ChatMessage::system("sys".to_string()),
|
|
greeting("Journal"),
|
|
ChatMessage::user("hi".to_string()),
|
|
];
|
|
let store = decode_store(&serde_json::to_string(&flat).unwrap()).unwrap();
|
|
assert_eq!(store.nodes.len(), 2);
|
|
assert!(
|
|
!store.nodes.iter().any(|n| n.message.role == "assistant"),
|
|
"greeting node survived the upgrade"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decode_store_keeps_a_real_reply_that_reads_like_the_greeting() {
|
|
// Same words, but after a user turn — a genuine reply, not the seed.
|
|
let flat = vec![
|
|
ChatMessage::system("sys".to_string()),
|
|
ChatMessage::user("who are you?".to_string()),
|
|
greeting("Journal"),
|
|
];
|
|
let store = decode_store(&serde_json::to_string(&flat).unwrap()).unwrap();
|
|
assert_eq!(store.nodes.len(), 3);
|
|
assert_eq!(store.nodes[2].message.role, "assistant");
|
|
}
|
|
|
|
#[test]
|
|
fn encode_decode_store_round_trips_a_forked_tree() {
|
|
let mut store = ChatHistoryStore::from_flat_array(vec![
|
|
ChatMessage::system("sys".to_string()),
|
|
ChatMessage::user("q".to_string()),
|
|
]);
|
|
let user_id = store.active_leaf_id;
|
|
let a1 = store.append_node(Some(user_id), assistant_msg("first answer"));
|
|
let a2 = store.append_node(Some(user_id), assistant_msg("second answer"));
|
|
store.active_leaf_id = a2;
|
|
|
|
let back = decode_store(&encode_store(&store).unwrap()).unwrap();
|
|
assert_eq!(back.active_leaf_id, a2);
|
|
assert_eq!(back.nodes.len(), 4);
|
|
assert_eq!(back.children_of(user_id).len(), 2);
|
|
assert_eq!(back.fork_at_node(a1), Some((1, 2)));
|
|
assert_eq!(back.fork_at_node(a2), Some((2, 2)));
|
|
}
|
|
|
|
#[test]
|
|
fn decode_store_rejects_garbage() {
|
|
let err = decode_store("not-json").unwrap_err();
|
|
assert!(err.to_string().contains("failed to deserialize"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_persona_path_never_marks_a_message_initial() {
|
|
// The file chat reserves is_initial for its synthetic "describe this
|
|
// photo" prompt, which the UI refuses to rewind or regenerate. A
|
|
// persona chat's first message is the user's own question, so both
|
|
// actions have to stay available on it.
|
|
let store = ChatHistoryStore::from_flat_array(vec![
|
|
ChatMessage::system("sys".to_string()),
|
|
ChatMessage::user("first question".to_string()),
|
|
assistant_msg("reply"),
|
|
]);
|
|
let path = store.path_to_leaf(store.active_leaf_id).unwrap();
|
|
let (rendered, turn_count, node_ids, fork_info) = render_persona_path(&store, &path);
|
|
|
|
assert_eq!(rendered.len(), 2, "the system node is not rendered");
|
|
assert!(rendered.iter().all(|m| !m.is_initial));
|
|
assert_eq!(rendered[0].role, "user");
|
|
assert_eq!(rendered[1].role, "assistant");
|
|
assert_eq!(turn_count, 1, "one assistant turn on this path");
|
|
assert_eq!(node_ids.len(), 2);
|
|
assert!(fork_info.iter().all(|f| f.is_none()), "no divergence yet");
|
|
}
|
|
|
|
#[test]
|
|
fn render_persona_path_marks_the_divergent_reply_on_a_forked_tree() {
|
|
let mut store = ChatHistoryStore::from_flat_array(vec![
|
|
ChatMessage::system("sys".to_string()),
|
|
ChatMessage::user("q".to_string()),
|
|
]);
|
|
let user_node = store.active_leaf_id;
|
|
let _a1 = store.append_node(Some(user_node), assistant_msg("first answer"));
|
|
let a2 = store.append_node(Some(user_node), assistant_msg("second answer"));
|
|
store.active_leaf_id = a2;
|
|
|
|
let path = store.path_to_leaf(a2).unwrap();
|
|
let (rendered, _turns, _ids, fork_info) = render_persona_path(&store, &path);
|
|
assert_eq!(rendered.len(), 2);
|
|
assert!(
|
|
fork_info[0].is_none(),
|
|
"the user turn itself has not forked"
|
|
);
|
|
let f = fork_info[1]
|
|
.as_ref()
|
|
.expect("divergence on the second reply");
|
|
assert_eq!((f.position, f.total), (2, 2));
|
|
assert_eq!(
|
|
f.node_id, user_node,
|
|
"divergence is attributed to the user turn"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wire_fork_info_preserves_position_total_and_node() {
|
|
let wired = wire_fork_info(vec![
|
|
None,
|
|
Some(ForkInfo {
|
|
position: 2,
|
|
total: 3,
|
|
node_id: 7,
|
|
}),
|
|
]);
|
|
assert!(wired[0].is_none());
|
|
let f = wired[1].as_ref().unwrap();
|
|
assert_eq!((f.position, f.total, f.node_id), (2, 3, 7));
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_history_view_empty_has_zero_counts() {
|
|
let v = PersonaChatHistoryView::empty();
|
|
assert_eq!(v.messages.len(), 0);
|
|
assert_eq!(v.turn_count, 0);
|
|
assert_eq!(v.active_leaf_id, 0);
|
|
assert_eq!(v.viewing_branch_id, 0);
|
|
assert!(v.fork_info.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_validation_flags_empty_user_message() {
|
|
let _req = PersonaChatTurnRequest {
|
|
conversation_id: "conv-1".to_string(),
|
|
user_message: " ".to_string(),
|
|
model: None,
|
|
backend: None,
|
|
num_ctx: None,
|
|
temperature: None,
|
|
top_p: None,
|
|
top_k: None,
|
|
min_p: None,
|
|
enable_thinking: None,
|
|
system_prompt: None,
|
|
library: None,
|
|
};
|
|
assert_eq!(
|
|
format!("{}", PersonaChatError::EmptyMessage),
|
|
"user_message must not be empty"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_validation_flags_oversized_user_message() {
|
|
let _req = PersonaChatTurnRequest {
|
|
conversation_id: "conv-1".to_string(),
|
|
user_message: "x".repeat(8193),
|
|
model: None,
|
|
backend: None,
|
|
num_ctx: None,
|
|
temperature: None,
|
|
top_p: None,
|
|
top_k: None,
|
|
min_p: None,
|
|
enable_thinking: None,
|
|
system_prompt: None,
|
|
library: None,
|
|
};
|
|
assert_eq!(
|
|
format!("{}", PersonaChatError::MessageTooLong),
|
|
"user_message exceeds 8192 chars"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_validation_flags_unknown_persona() {
|
|
let err = PersonaChatError::UnknownPersona("nope".to_string());
|
|
assert_eq!(format!("{err}"), "persona 'nope' not found");
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_error_concurrent_turn_displays_message() {
|
|
assert_eq!(
|
|
format!("{}", PersonaChatError::ConcurrentTurn),
|
|
"another turn for this persona is in flight"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_turn_returns_409_when_concurrent_turn_in_flight() {
|
|
let in_flight = InFlightPersonaTurns::new();
|
|
assert!(in_flight.claim("conv-a", "t1").is_ok());
|
|
|
|
// A second dispatch against the same conversation is rejected with
|
|
// the in-flight turn's id (the handler maps this to HTTP 409),
|
|
// and the rejection does not clobber the in-flight claim.
|
|
assert_eq!(in_flight.claim("conv-a", "t2").unwrap_err(), "t1");
|
|
assert_eq!(in_flight.claim("conv-a", "t3").unwrap_err(), "t1");
|
|
|
|
// A second conversation with the same persona runs independently —
|
|
// that is the whole point of separate conversations.
|
|
assert!(in_flight.claim("conv-b", "t4").is_ok());
|
|
|
|
// Releasing frees the slot for a fresh dispatch.
|
|
in_flight.release("conv-a");
|
|
assert!(in_flight.claim("conv-a", "t6").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn in_flight_guard_releases_slot_on_drop() {
|
|
let in_flight = Arc::new(InFlightPersonaTurns::new());
|
|
let tracker = in_flight.clone();
|
|
// Mirrors dispatch_turn: claim first, guard holds the slot.
|
|
assert!(tracker.claim("conv-a", "t-guard").is_ok());
|
|
{
|
|
let _guard = InFlightGuard {
|
|
in_flight,
|
|
conversation_id: "conv-a".to_string(),
|
|
};
|
|
assert_eq!(tracker.claim("conv-a", "other").unwrap_err(), "t-guard");
|
|
}
|
|
// Guard dropped → slot free again.
|
|
assert!(tracker.claim("conv-a", "t7").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_turn_request_deserializes_snake_case_wire_body() {
|
|
// Pins the mobile-client contract: snake_case keys, same as the
|
|
// file-chat request shapes. A camelCase regression here 400s the
|
|
// client's dispatch with "missing field persona_id".
|
|
let body = r#"{
|
|
"conversation_id": "conv-1",
|
|
"user_message": "hello",
|
|
"num_ctx": 4096,
|
|
"top_p": 0.9,
|
|
"top_k": 40,
|
|
"min_p": 0.05,
|
|
"enable_thinking": true,
|
|
"system_prompt": "be brief",
|
|
"library": "main"
|
|
}"#;
|
|
let req: PersonaChatTurnRequest = serde_json::from_str(body).unwrap();
|
|
assert_eq!(req.conversation_id, "conv-1");
|
|
assert_eq!(req.user_message, "hello");
|
|
assert_eq!(req.num_ctx, Some(4096));
|
|
assert_eq!(req.top_p, Some(0.9));
|
|
assert_eq!(req.top_k, Some(40));
|
|
assert_eq!(req.min_p, Some(0.05));
|
|
assert_eq!(req.enable_thinking, Some(true));
|
|
assert_eq!(req.system_prompt.as_deref(), Some("be brief"));
|
|
assert_eq!(req.library.as_deref(), Some("main"));
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_history_view_serializes_snake_case_wire_shape() {
|
|
let view = PersonaChatHistoryView {
|
|
conversation_id: "conv-1".to_string(),
|
|
persona_id: "journal".to_string(),
|
|
title: "June recap".to_string(),
|
|
messages: vec![RenderedPersonaMessage {
|
|
role: "user".to_string(),
|
|
content: "hi".to_string(),
|
|
is_initial: true,
|
|
tools: Vec::new(),
|
|
}],
|
|
turn_count: 1,
|
|
model_version: "x".to_string(),
|
|
backend: "local".to_string(),
|
|
active_leaf_id: 4,
|
|
viewing_branch_id: 2,
|
|
fork_info: vec![Some(crate::ai::handlers::ChatForkInfo {
|
|
position: 1,
|
|
total: 2,
|
|
node_id: 3,
|
|
})],
|
|
};
|
|
let json = serde_json::to_value(&view).unwrap();
|
|
// The client's shared ChatHistoryView reads snake_case keys.
|
|
assert_eq!(json["conversation_id"], "conv-1");
|
|
assert_eq!(json["persona_id"], "journal");
|
|
assert_eq!(json["title"], "June recap");
|
|
assert_eq!(json["turn_count"], 1);
|
|
assert_eq!(json["active_leaf_id"], 4);
|
|
assert_eq!(json["viewing_branch_id"], 2);
|
|
assert_eq!(json["messages"][0]["is_initial"], true);
|
|
assert!(json["messages"][0].get("tools").is_none());
|
|
// fork_info matches the file chat's shape so the client's shared
|
|
// branch-picker code can read either surface.
|
|
assert_eq!(json["fork_info"][0]["position"], 1);
|
|
assert_eq!(json["fork_info"][0]["total"], 2);
|
|
assert_eq!(json["fork_info"][0]["node_id"], 3);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_create_request_deserializes_snake_case_wire_body() {
|
|
let req: PersonaChatCreateRequest =
|
|
serde_json::from_str(r#"{"persona_id": "journal"}"#).unwrap();
|
|
assert_eq!(req.persona_id, "journal");
|
|
}
|
|
|
|
/// system → user → assistant, plus a second assistant forking off the
|
|
/// user turn. Returns (store, rendered node ids on the active path).
|
|
fn forked_store() -> (ChatHistoryStore, Vec<u64>) {
|
|
let mut store = ChatHistoryStore::from_flat_array(vec![
|
|
ChatMessage::system("sys".to_string()),
|
|
ChatMessage::user("q".to_string()),
|
|
]);
|
|
let user_node = store.active_leaf_id;
|
|
let _a1 = store.append_node(Some(user_node), assistant_msg("first answer"));
|
|
let a2 = store.append_node(Some(user_node), assistant_msg("second answer"));
|
|
store.active_leaf_id = a2;
|
|
let path = store.path_to_leaf(a2).unwrap();
|
|
let (_r, _t, node_ids, _f) = render_persona_path(&store, &path);
|
|
(store, node_ids)
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_target_leaf_keeps_everything_before_the_discarded_index() {
|
|
let (store, node_ids) = forked_store();
|
|
// Discard the reply (index 1) — the user turn at index 0 stays, and
|
|
// the reply survives as a fork rather than being deleted.
|
|
let leaf = rewind_target_leaf(&store, &node_ids, 1).unwrap();
|
|
assert_eq!(leaf, node_ids[0]);
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_target_leaf_at_zero_reanchors_on_the_seed_node() {
|
|
// The file chat refuses index 0 because its index 0 is a synthetic
|
|
// prompt. Here it is the user's own question, so editing it has to
|
|
// work — the anchor becomes the system node above it.
|
|
let (store, node_ids) = forked_store();
|
|
let leaf = rewind_target_leaf(&store, &node_ids, 0).unwrap();
|
|
assert_eq!(leaf, store.nodes[0].id, "anchors on the seed system node");
|
|
assert_eq!(store.nodes[0].message.role, "system");
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_target_leaf_rejects_an_index_past_the_transcript() {
|
|
let (store, node_ids) = forked_store();
|
|
let err = rewind_target_leaf(&store, &node_ids, 99).unwrap_err();
|
|
assert!(err.to_string().contains("out of range"));
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_target_leaf_errors_when_nothing_sits_above_index_zero() {
|
|
// A transcript with no seed system node has nothing to re-anchor on.
|
|
let store = ChatHistoryStore::from_flat_array(vec![ChatMessage::user("q".to_string())]);
|
|
let path = store.path_to_leaf(store.active_leaf_id).unwrap();
|
|
let (_r, _t, node_ids, _f) = render_persona_path(&store, &path);
|
|
let err = rewind_target_leaf(&store, &node_ids, 0).unwrap_err();
|
|
assert!(err.to_string().contains("cannot rewind past the start"));
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_target_leaf_rejects_an_empty_transcript() {
|
|
let store = ChatHistoryStore::from_flat_array(Vec::new());
|
|
let err = rewind_target_leaf(&store, &[], 0).unwrap_err();
|
|
assert!(err.to_string().contains("out of range"));
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_title_strips_the_quotes_small_models_add() {
|
|
assert_eq!(sanitize_title("\"June recap\""), "June recap");
|
|
assert_eq!(sanitize_title("'June recap'"), "June recap");
|
|
assert_eq!(sanitize_title("“June recap”"), "June recap");
|
|
assert_eq!(sanitize_title("**June recap**"), "June recap");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_title_strips_a_label_prefix_and_trailing_period() {
|
|
assert_eq!(sanitize_title("Title: June recap."), "June recap");
|
|
assert_eq!(sanitize_title("Chat title: June recap"), "June recap");
|
|
assert_eq!(
|
|
sanitize_title("CONVERSATION TITLE: June recap"),
|
|
"June recap"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_title_takes_the_first_line_of_a_chatty_reply() {
|
|
// Small models like to explain themselves after answering.
|
|
assert_eq!(
|
|
sanitize_title("June recap\n\nI chose this because it summarizes…"),
|
|
"June recap"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_title_collapses_whitespace() {
|
|
assert_eq!(sanitize_title(" June recap \t "), "June recap");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_title_truncates_on_a_character_boundary() {
|
|
// Byte-index slicing here would panic on the first accented word.
|
|
let long = "é".repeat(TITLE_MAX_CHARS + 20);
|
|
let title = sanitize_title(&long);
|
|
assert_eq!(title.chars().count(), TITLE_MAX_CHARS + 1);
|
|
assert!(title.ends_with('…'));
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_title_returns_empty_for_junk() {
|
|
assert_eq!(sanitize_title(" "), "");
|
|
assert_eq!(sanitize_title("\"\""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_title_names_a_conversation_after_the_opening_question() {
|
|
// Better than "Untitled": it is the thing the user scans for.
|
|
let messages = vec![
|
|
ChatMessage::system("sys".to_string()),
|
|
ChatMessage::user("What happened in June?".to_string()),
|
|
assistant_msg("Quite a lot."),
|
|
];
|
|
assert_eq!(fallback_title(&messages), "What happened in June?");
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_title_is_empty_without_a_user_message() {
|
|
let messages = vec![ChatMessage::system("sys".to_string())];
|
|
assert_eq!(fallback_title(&messages), "");
|
|
}
|
|
|
|
#[test]
|
|
fn title_prompt_carries_both_sides_of_the_opening_exchange() {
|
|
let prompt = title_prompt("What happened in June?", "Quite a lot.");
|
|
assert!(prompt.contains("What happened in June?"));
|
|
assert!(prompt.contains("Quite a lot."));
|
|
// The instruction the sanitizer is the backstop for.
|
|
assert!(prompt.contains("at most six"));
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_snippet_flattens_whitespace_to_one_line() {
|
|
// A markdown reply must not blow up the row height on the list.
|
|
let snippet = conversation_snippet("first line\n\n- bullet\n- another");
|
|
assert_eq!(snippet, "first line - bullet - another");
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_snippet_leaves_a_short_message_untouched() {
|
|
assert_eq!(conversation_snippet("hello there"), "hello there");
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_snippet_truncates_on_a_character_boundary() {
|
|
// Multi-byte characters: a byte-index slice here would panic.
|
|
let content = "é".repeat(SNIPPET_MAX_CHARS + 50);
|
|
let snippet = conversation_snippet(&content);
|
|
assert_eq!(snippet.chars().count(), SNIPPET_MAX_CHARS + 1);
|
|
assert!(snippet.ends_with('…'));
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_snippet_handles_an_empty_message() {
|
|
assert_eq!(conversation_snippet(" "), "");
|
|
}
|
|
|
|
#[test]
|
|
fn persona_conversation_summary_serializes_snake_case_wire_shape() {
|
|
let summary = PersonaConversationSummary {
|
|
conversation_id: "conv-1".to_string(),
|
|
persona_id: "journal".to_string(),
|
|
persona_name: "Journal".to_string(),
|
|
title: "June recap".to_string(),
|
|
snippet: "last thing said".to_string(),
|
|
snippet_role: "assistant".to_string(),
|
|
turn_count: 3,
|
|
message_count: 6,
|
|
created_at: 1_600_000_000_000,
|
|
updated_at: 1_700_000_000_000,
|
|
has_branches: true,
|
|
};
|
|
let json = serde_json::to_value(&summary).unwrap();
|
|
assert_eq!(json["conversation_id"], "conv-1");
|
|
assert_eq!(json["title"], "June recap");
|
|
assert_eq!(json["persona_id"], "journal");
|
|
assert_eq!(json["persona_name"], "Journal");
|
|
assert_eq!(json["snippet_role"], "assistant");
|
|
assert_eq!(json["turn_count"], 3);
|
|
assert_eq!(json["message_count"], 6);
|
|
assert_eq!(json["updated_at"], 1_700_000_000_000i64);
|
|
assert_eq!(json["has_branches"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_rewind_request_deserializes_snake_case_wire_body() {
|
|
let req: PersonaChatRewindRequest = serde_json::from_str(
|
|
r#"{"conversation_id": "conv-1", "discard_from_rendered_index": 3}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(req.conversation_id, "conv-1");
|
|
assert_eq!(req.discard_from_rendered_index, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_switch_branch_request_deserializes_snake_case_wire_body() {
|
|
let req: PersonaChatSwitchBranchRequest =
|
|
serde_json::from_str(r#"{"conversation_id": "conv-1", "branch_id": 9}"#).unwrap();
|
|
assert_eq!(req.conversation_id, "conv-1");
|
|
assert_eq!(req.branch_id, 9);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_branches_query_defaults_the_optional_scoping_params() {
|
|
let q: PersonaChatBranchesQuery =
|
|
serde_json::from_str(r#"{"conversation_id": "conv-1"}"#).unwrap();
|
|
assert_eq!(q.node_id, None, "unscoped listing walks every leaf");
|
|
assert_eq!(q.viewing_branch_id, None);
|
|
|
|
let q: PersonaChatBranchesQuery = serde_json::from_str(
|
|
r#"{"conversation_id": "conv-1", "node_id": 4, "viewing_branch_id": 6}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(q.node_id, Some(4));
|
|
assert_eq!(q.viewing_branch_id, Some(6));
|
|
}
|
|
|
|
#[test]
|
|
fn branch_options_at_ranks_the_siblings_of_a_divergence() {
|
|
// The list the branch picker renders: both replies to the same user
|
|
// turn, position-ranked, with the viewed one anchored.
|
|
let (store, _node_ids) = forked_store();
|
|
let user_node = store.nodes[1].id;
|
|
let options = store.branch_options_at(user_node, store.active_leaf_id);
|
|
assert_eq!(options.len(), 2);
|
|
assert_eq!(options[0].position, Some(1));
|
|
assert_eq!(options[1].position, Some(2));
|
|
assert!(
|
|
options.iter().any(|o| o.id == store.active_leaf_id),
|
|
"the branch being viewed is one of the options"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_history_query_accepts_an_optional_branch_id() {
|
|
let q: PersonaChatHistoryQuery =
|
|
serde_json::from_str(r#"{"conversation_id": "conv-1"}"#).unwrap();
|
|
assert_eq!(
|
|
q.branch_id, None,
|
|
"omitting branch_id renders the active branch"
|
|
);
|
|
|
|
let q: PersonaChatHistoryQuery =
|
|
serde_json::from_str(r#"{"conversation_id": "conv-1", "branch_id": 12}"#).unwrap();
|
|
assert_eq!(q.branch_id, Some(12));
|
|
}
|
|
}
|