Feature/open chat with persona #110

Merged
cameron merged 4 commits from feature/open-chat-with-persona into master 2026-09-08 21:55:38 +00:00
13 changed files with 1848 additions and 455 deletions
Showing only changes of commit 65cceaa67c - Show all commits
Generated
+1 -1
View File
@@ -2051,7 +2051,7 @@ dependencies = [
[[package]] [[package]]
name = "image-api" name = "image-api"
version = "1.4.0" version = "1.5.0"
dependencies = [ dependencies = [
"actix", "actix",
"actix-cors", "actix-cors",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "image-api" name = "image-api"
version = "1.4.0" version = "1.5.0"
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"] authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
edition = "2024" edition = "2024"
@@ -0,0 +1,32 @@
-- Collapse back to one conversation per (user, persona). Where a persona has
-- several, the most recently updated one wins and the rest are dropped —
-- the v1 schema has nowhere to put them.
CREATE TABLE persona_chat_conversations_old (
user_id INTEGER NOT NULL,
persona_id TEXT NOT NULL,
messages_json TEXT NOT NULL DEFAULT '[]',
turn_count INTEGER NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, persona_id)
);
INSERT INTO persona_chat_conversations_old (
user_id, persona_id, messages_json, turn_count, updated_at
)
SELECT user_id, persona_id, messages_json, turn_count, updated_at
FROM persona_chat_conversations c
WHERE c.updated_at = (
SELECT MAX(c2.updated_at)
FROM persona_chat_conversations c2
WHERE c2.user_id = c.user_id AND c2.persona_id = c.persona_id
)
GROUP BY user_id, persona_id;
DROP INDEX IF EXISTS idx_persona_chat_updated;
DROP INDEX IF EXISTS idx_persona_chat_persona;
DROP TABLE persona_chat_conversations;
ALTER TABLE persona_chat_conversations_old RENAME TO persona_chat_conversations;
CREATE INDEX idx_persona_chat_updated
ON persona_chat_conversations (user_id, updated_at DESC);
@@ -0,0 +1,50 @@
-- Multiple conversations per persona.
--
-- v1 keyed a transcript on (user_id, persona_id), so a persona had exactly
-- one rolling conversation and there was no way to start a fresh topic
-- without discarding the old one. The key is now an opaque `conversation_id`,
-- with (user_id, persona_id) demoted to an index.
--
-- `title` is a short generated summary of the opening exchange, used as the
-- conversation's name in the list. Empty until the first turn completes; the
-- client falls back to the persona name while it is blank.
--
-- SQLite cannot redefine a primary key in place, so this is the standard
-- create-copy-drop-rename dance. Existing transcripts carry over with a
-- generated id and an empty title.
CREATE TABLE persona_chat_conversations_new (
conversation_id TEXT NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL,
persona_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
messages_json TEXT NOT NULL DEFAULT '[]',
turn_count INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
INSERT INTO persona_chat_conversations_new (
conversation_id, user_id, persona_id, title,
messages_json, turn_count, created_at, updated_at
)
SELECT
lower(hex(randomblob(16))),
user_id,
persona_id,
'',
messages_json,
turn_count,
updated_at,
updated_at
FROM persona_chat_conversations;
DROP INDEX IF EXISTS idx_persona_chat_updated;
DROP TABLE persona_chat_conversations;
ALTER TABLE persona_chat_conversations_new RENAME TO persona_chat_conversations;
CREATE INDEX idx_persona_chat_updated
ON persona_chat_conversations (user_id, updated_at DESC);
CREATE INDEX idx_persona_chat_persona
ON persona_chat_conversations (user_id, persona_id);
+17 -8
View File
@@ -30,11 +30,11 @@ pub const DEFAULT_MAX_ITERATIONS: usize = 6;
const DEFAULT_NUM_CTX: i32 = 32768; const DEFAULT_NUM_CTX: i32 = 32768;
/// Headroom reserved for the model's response, deducted from the context /// Headroom reserved for the model's response, deducted from the context
/// budget when deciding whether to truncate the replayed history. /// budget when deciding whether to truncate the replayed history.
const RESPONSE_HEADROOM_TOKENS: usize = 2048; pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048;
/// Cheap byte-to-token approximation used by the truncation pass. The real /// Cheap byte-to-token approximation used by the truncation pass. The real
/// tokenization is model-specific; this avoids carrying tiktoken just for a /// tokenization is model-specific; this avoids carrying tiktoken just for a
/// soft bound. /// soft bound.
const BYTES_PER_TOKEN: usize = 4; pub(crate) const BYTES_PER_TOKEN: usize = 4;
/// Flat token cost charged per inlined image in the truncation budget. A /// Flat token cost charged per inlined image in the truncation budget. A
/// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on /// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on
/// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of /// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of
@@ -335,6 +335,13 @@ impl InsightChatService {
if truncated { if truncated {
span.set_attribute(KeyValue::new("history_truncated", true)); span.set_attribute(KeyValue::new("history_truncated", true));
} }
// Everything in `messages` at this point is replayed history that is
// ALREADY in the tree; only what the turn appends past this mark
// becomes new nodes. Captured after the budget pass, not from
// `path.len()`: truncation drains from the middle, so `path.len()`
// over-counts and the slice below would either swallow the new user
// turn or panic on an out-of-range start index.
let history_len = messages.len();
// 7. Append the new user turn. // 7. Append the new user turn.
messages.push(ChatMessage::user(req.user_message.clone())); messages.push(ChatMessage::user(req.user_message.clone()));
@@ -461,8 +468,7 @@ impl InsightChatService {
// relying on store_insight to flip prior rows' is_current=false. // relying on store_insight to flip prior rows' is_current=false.
// Append new messages (this turn's user + assistant exchanges) as // Append new messages (this turn's user + assistant exchanges) as
// tree nodes chained from the previous active_leaf_id. // tree nodes chained from the previous active_leaf_id.
let path_len = path.len(); let new_messages = messages[history_len..].to_vec();
let new_messages = messages[path_len..].to_vec();
let mut parent_id = Some(store.active_leaf_id); let mut parent_id = Some(store.active_leaf_id);
for msg in &new_messages { for msg in &new_messages {
let new_id = store.append_node(parent_id, msg.clone()); let new_id = store.append_node(parent_id, msg.clone());
@@ -942,7 +948,6 @@ impl InsightChatService {
let path = store let path = store
.path_to_leaf(store.active_leaf_id) .path_to_leaf(store.active_leaf_id)
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?; .ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
let path_len = path.len();
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect(); let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
let stored_backend = insight.backend.clone(); let stored_backend = insight.backend.clone();
@@ -1003,6 +1008,10 @@ impl InsightChatService {
if truncated { if truncated {
let _ = entry.push_event(ChatStreamEvent::Truncated).await; let _ = entry.push_event(ChatStreamEvent::Truncated).await;
} }
// See the note in `chat_turn`: the new-node boundary must be read
// after the budget pass, because truncation drains replayed history
// out of the middle of `messages`.
let history_len = messages.len();
messages.push(ChatMessage::user(req.user_message.clone())); messages.push(ChatMessage::user(req.user_message.clone()));
@@ -1046,7 +1055,7 @@ impl InsightChatService {
} }
// Append new messages as tree nodes. // Append new messages as tree nodes.
let new_messages = messages[path_len..].to_vec(); let new_messages = messages[history_len..].to_vec();
let mut parent_id = Some(store.active_leaf_id); let mut parent_id = Some(store.active_leaf_id);
for msg in &new_messages { for msg in &new_messages {
let new_id = store.append_node(parent_id, msg.clone()); let new_id = store.append_node(parent_id, msg.clone());
@@ -2219,7 +2228,7 @@ pub fn env_max_iterations() -> usize {
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context /// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context
/// window for the truncation budget when the request omits `num_ctx`. Same /// window for the truncation budget when the request omits `num_ctx`. Same
/// no-static-global rationale as `env_max_iterations` above. /// no-static-global rationale as `env_max_iterations` above.
fn env_default_num_ctx() -> i32 { pub(crate) fn env_default_num_ctx() -> i32 {
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX") std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX")
.ok() .ok()
.and_then(|s| s.parse::<i32>().ok()) .and_then(|s| s.parse::<i32>().ok())
@@ -2534,7 +2543,7 @@ pub struct ForkInfo {
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to /// tool-dispatch assistant (empty content + tool_calls) is still attributed to
/// the next rendered message. Detecting forks only on rendered nodes would miss /// the next rendered message. Detecting forks only on rendered nodes would miss
/// regenerations where the model replied with a tool call. /// regenerations where the model replied with a tool call.
fn render_tree_path( pub(crate) fn render_tree_path(
store: &ChatHistoryStore, store: &ChatHistoryStore,
path: &[&StoredChatNode], path: &[&StoredChatNode],
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) { ) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {
+2 -2
View File
@@ -407,8 +407,8 @@ impl ChatHistoryStore {
.collect() .collect()
} }
/// The parent of the given node, if any. Test-only helper. /// The parent of the given node, if any. Used to re-anchor a rewind
#[cfg(test)] /// that discards every rendered message onto the seed node above them.
pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> { pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> {
let node = self.nodes.iter().find(|n| n.id == node_id)?; let node = self.nodes.iter().find(|n| n.id == node_id)?;
let parent_id = node.parent_id?; let parent_id = node.parent_id?;
+7 -5
View File
@@ -7,13 +7,13 @@ 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;
pub mod nl_query; pub mod nl_query;
pub mod ollama; pub mod ollama;
pub mod openrouter; pub mod openrouter;
pub mod persona_chat;
pub mod pronunciation; pub mod pronunciation;
pub mod sms_client; pub mod sms_client;
pub mod tts; pub mod tts;
@@ -33,16 +33,18 @@ 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)]
pub use llm_client::{ pub use llm_client::{
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction, ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
}; };
pub use persona_chat::{
persona_chat_branches_handler, persona_chat_conversations_handler,
persona_chat_create_conversation_handler, persona_chat_delete_conversation_handler,
persona_chat_history_handler, persona_chat_rewind_handler, persona_chat_switch_branch_handler,
persona_chat_turn_handler, persona_turn_cancel_handler, persona_turn_replay_handler,
};
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server // LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
#[allow(unused_imports)] #[allow(unused_imports)]
pub use local_llm::LocalLlm; pub use local_llm::LocalLlm;
+1373 -308
View File
File diff suppressed because it is too large Load Diff
+342 -111
View File
@@ -13,16 +13,48 @@ use crate::otel::trace_db_call;
/// One row of the persona-chat transcript. Lives in /// One row of the persona-chat transcript. Lives in
/// `persona_chat_conversations` (one row per `(user_id, persona_id)`). /// `persona_chat_conversations` (one row per `(user_id, persona_id)`).
/// ///
/// `turn_count` is the number of new turn-rows since the last persisted /// One persona conversation.
/// slice — used by the SSE `done` event for stats dashboards. The actual ///
/// transcript is the `messages_json` blob. /// `turn_count` is the cumulative number of assistant turns on the active
/// branch — what the list screen shows as the length of a conversation. The
/// transcript itself is the `messages_json` blob, holding a serialized
/// `ChatHistoryStore` tree. `title` is a generated summary of the opening
/// exchange, empty until the first turn completes.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PersonaChatRow { pub struct PersonaChatRow {
pub conversation_id: String,
pub persona_id: String,
pub title: String,
pub messages_json: String, pub messages_json: String,
pub turn_count: i32, pub turn_count: i32,
pub created_at: i64,
pub updated_at: i64, pub updated_at: i64,
} }
/// Column tuple → `PersonaChatRow`. Shared by the single-row and list
/// queries so their `select(...)` orders can never drift apart.
fn persona_chat_row(
(conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at): (
String,
String,
String,
String,
i32,
i64,
i64,
),
) -> PersonaChatRow {
PersonaChatRow {
conversation_id,
persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
}
}
/// Patch shape for update_persona. None = leave field alone. Built-ins are /// Patch shape for update_persona. None = leave field alone. Built-ins are
/// allowed to flip `include_all_memories` but should reject name/prompt /// allowed to flip `include_all_memories` but should reject name/prompt
/// edits at the handler layer (built-in copy lives in the migration). /// edits at the handler layer (built-in copy lives in the migration).
@@ -96,39 +128,72 @@ pub trait PersonaDao: Sync + Send {
// ── Persona-chat (open chat with persona) persistence ─────────── // ── Persona-chat (open chat with persona) persistence ───────────
// //
// Keyed by `(user_id, persona_id)` with a single rolling transcript // Keyed on an opaque `conversation_id` so one persona can hold several
// per pair. No tree branching in v1 — matches the locked-in scope. // separate conversations. `user_id` is checked on every read and write:
// the id is a bearer token for a transcript, so ownership can never be
// assumed from the id alone.
/// Fetch the rolling transcript for one `(user, persona)`. None when /// Fetch one conversation. None when it doesn't exist or belongs to
/// the user has never started a conversation with this persona. /// another user — the two are deliberately indistinguishable to callers.
fn get_persona_chat( fn get_persona_chat(
&mut self, &mut self,
cx: &opentelemetry::Context, cx: &opentelemetry::Context,
user_id: i32, user_id: i32,
persona_id: &str, conversation_id: &str,
) -> Result<Option<PersonaChatRow>, DbError>; ) -> Result<Option<PersonaChatRow>, DbError>;
/// Upsert (create-or-replace) the rolling transcript. Called once per /// Every conversation this user has going, newest first. Backs the chat
/// completed turn with the full new `messages_json`. The `turn_count` /// list screen, so it returns the transcript blob too — the snippet is
/// is the number of user/assistant pairs added in this write. /// derived from the tree rather than denormalized into its own column.
fn upsert_persona_chat( fn list_persona_chats(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
) -> Result<Vec<PersonaChatRow>, DbError>;
/// Start a new conversation with a persona, returning its id. Several
/// conversations with the same persona are expected, so this never
/// reuses an existing row.
fn create_persona_chat(
&mut self, &mut self,
cx: &opentelemetry::Context, cx: &opentelemetry::Context,
user_id: i32, user_id: i32,
persona_id: &str, persona_id: &str,
created_at: i64,
) -> Result<String, DbError>;
/// Replace a conversation's transcript. Called once per completed turn
/// with the full new `messages_json`; `turn_count` is the cumulative
/// assistant-turn count on the resulting active branch. Returns the
/// number of rows written — 0 means the conversation is gone or is not
/// this user's.
fn update_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
messages_json: &str, messages_json: &str,
turn_count: i32, turn_count: i32,
updated_at: i64, updated_at: i64,
) -> Result<(), DbError>; ) -> Result<usize, DbError>;
/// Wipe the rolling transcript. Reserved for a future "New /// Name a conversation. Written once, after the first turn produces
/// conversation" affordance; the row itself stays (so a subsequent /// enough of an exchange to summarize.
/// get returns None, not 404). fn set_persona_chat_title(
fn clear_persona_chat(
&mut self, &mut self,
cx: &opentelemetry::Context, cx: &opentelemetry::Context,
user_id: i32, user_id: i32,
persona_id: &str, conversation_id: &str,
title: &str,
) -> Result<(), DbError>;
/// Delete a conversation outright. Backs both the list screen's delete
/// affordance and the reset endpoint.
fn delete_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
) -> Result<(), DbError>; ) -> Result<(), DbError>;
} }
@@ -351,84 +416,155 @@ impl PersonaDao for SqlitePersonaDao {
&mut self, &mut self,
cx: &opentelemetry::Context, cx: &opentelemetry::Context,
uid: i32, uid: i32,
pid: &str, cid: &str,
) -> Result<Option<PersonaChatRow>, DbError> { ) -> Result<Option<PersonaChatRow>, DbError> {
trace_db_call(cx, "query", "get_persona_chat", |_span| { trace_db_call(cx, "query", "get_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*; use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock"); let mut conn = self.connection.lock().expect("PersonaDao lock");
persona_chat_conversations persona_chat_conversations
.filter(conversation_id.eq(cid))
// Scoped by user as well as id: the id is a bearer token for
// someone's transcript, so it never grants access on its own.
.filter(user_id.eq(uid)) .filter(user_id.eq(uid))
.filter(persona_id.eq(pid)) .select((
.select((messages_json, turn_count, updated_at)) conversation_id,
.first::<(String, i32, i64)>(conn.deref_mut()) persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
))
.first::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
.optional() .optional()
.map(|opt| { .map(|opt| opt.map(persona_chat_row))
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| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|e| DbError::log(DbErrorKind::QueryError, e)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn upsert_persona_chat( fn list_persona_chats(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
) -> Result<Vec<PersonaChatRow>, DbError> {
trace_db_call(cx, "query", "list_persona_chats", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
persona_chat_conversations
.filter(user_id.eq(uid))
.order(updated_at.desc())
.select((
conversation_id,
persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
))
.load::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
.map(|rows| rows.into_iter().map(persona_chat_row).collect())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn create_persona_chat(
&mut self, &mut self,
cx: &opentelemetry::Context, cx: &opentelemetry::Context,
uid: i32, uid: i32,
pid: &str, pid: &str,
json: &str, created: i64,
count: i32, ) -> Result<String, DbError> {
updated_at: i64, trace_db_call(cx, "insert", "create_persona_chat", |_span| {
) -> Result<(), DbError> { use schema::persona_chat_conversations::dsl::*;
trace_db_call(cx, "upsert", "upsert_persona_chat", |_span| {
let mut conn = self.connection.lock().expect("PersonaDao lock"); let mut conn = self.connection.lock().expect("PersonaDao lock");
// INSERT OR REPLACE on the (user_id, persona_id) PRIMARY KEY — let new_id = uuid::Uuid::new_v4().to_string();
// single rolling transcript, so a new write always supersedes diesel::insert_into(persona_chat_conversations)
// the prior one in full. The mobile hook serialises turns with .values((
// a per-persona mutex, so this never races. Plain conversation_id.eq(&new_id),
// `sql_query` sidesteps a Diesel type-recursion blow-up that user_id.eq(uid),
// hits `insert_into(...).on_conflict(...).do_update().set(...)` persona_id.eq(pid),
// with this many typed columns. title.eq(""),
diesel::sql_query( // The empty tree; the first turn seeds it with the
"INSERT INTO persona_chat_conversations \ // persona's system prompt.
(user_id, persona_id, messages_json, turn_count, updated_at) \ messages_json.eq("[]"),
VALUES (?, ?, ?, ?, ?) \ turn_count.eq(0),
ON CONFLICT(user_id, persona_id) DO UPDATE SET \ created_at.eq(created),
messages_json = excluded.messages_json, \ updated_at.eq(created),
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()) .execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Upsert error: {}", e))?; .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
Ok(()) Ok(new_id)
}) })
.map_err(|e| DbError::log(DbErrorKind::InsertError, e)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn clear_persona_chat( fn update_persona_chat(
&mut self, &mut self,
cx: &opentelemetry::Context, cx: &opentelemetry::Context,
uid: i32, uid: i32,
pid: &str, cid: &str,
) -> Result<(), DbError> { json: &str,
trace_db_call(cx, "delete", "clear_persona_chat", |_span| { count: i32,
updated: i64,
) -> Result<usize, DbError> {
trace_db_call(cx, "update", "update_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::update(
persona_chat_conversations
.filter(conversation_id.eq(cid))
.filter(user_id.eq(uid)),
)
.set((
messages_json.eq(json),
turn_count.eq(count),
updated_at.eq(updated),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn set_persona_chat_title(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
new_title: &str,
) -> Result<(), DbError> {
trace_db_call(cx, "update", "set_persona_chat_title", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::update(
persona_chat_conversations
.filter(conversation_id.eq(cid))
.filter(user_id.eq(uid)),
)
.set(title.eq(new_title))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
Ok(())
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn delete_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
) -> Result<(), DbError> {
trace_db_call(cx, "delete", "delete_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*; use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock"); 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( diesel::delete(
persona_chat_conversations persona_chat_conversations
.filter(user_id.eq(uid)) .filter(conversation_id.eq(cid))
.filter(persona_id.eq(pid)), .filter(user_id.eq(uid)),
) )
.execute(conn.deref_mut()) .execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
@@ -437,7 +573,6 @@ impl PersonaDao for SqlitePersonaDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -587,74 +722,170 @@ mod tests {
// ── Persona-chat DAO tests ───────────────────────────────────── // ── Persona-chat DAO tests ─────────────────────────────────────
#[test] /// Second user, for the isolation tests.
fn persona_chat_get_returns_none_for_never_started() { fn second_user(dao: &SqlitePersonaDao, username: &str) -> i32 {
let cx = opentelemetry::Context::new(); use crate::database::schema::users::dsl as u;
let (mut dao, uid) = dao_with_user("p1"); let conn = dao.connection.clone();
let row = dao.get_persona_chat(&cx, uid, "default").unwrap(); diesel::insert_into(u::users)
assert!(row.is_none()); .values((u::username.eq(username), u::password.eq("x")))
.execute(conn.lock().unwrap().deref_mut())
.unwrap();
u::users
.filter(u::username.eq(username))
.select(u::id)
.first(conn.lock().unwrap().deref_mut())
.unwrap()
} }
#[test] #[test]
fn persona_chat_upsert_then_get_round_trip() { fn persona_chat_get_returns_none_for_an_unknown_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p1");
assert!(
dao.get_persona_chat(&cx, uid, "no-such-id")
.unwrap()
.is_none()
);
}
#[test]
fn persona_chat_create_then_get_round_trip() {
let cx = opentelemetry::Context::new(); let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p2"); let (mut dao, uid) = dao_with_user("p2");
dao.upsert_persona_chat(&cx, uid, "journal", "[]", 1, 100) let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
.unwrap();
let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap(); let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "[]"); assert_eq!(row.conversation_id, cid);
assert_eq!(row.turn_count, 1); assert_eq!(row.persona_id, "journal");
assert_eq!(row.title, "", "unnamed until the first turn completes");
assert_eq!(row.turn_count, 0);
assert_eq!(row.created_at, 100);
assert_eq!(row.updated_at, 100); assert_eq!(row.updated_at, 100);
} }
#[test] #[test]
fn persona_chat_upsert_replaces_existing_row() { fn persona_chat_create_makes_a_separate_row_each_time() {
// The whole point of the conversation_id key: one persona, several
// independent threads.
let cx = opentelemetry::Context::new(); let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p3"); let (mut dao, uid) = dao_with_user("p3");
dao.upsert_persona_chat(&cx, uid, "journal", "first", 1, 100).unwrap(); let first = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
dao.upsert_persona_chat(&cx, uid, "journal", "second", 2, 200).unwrap(); let second = dao.create_persona_chat(&cx, uid, "journal", 200).unwrap();
let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap();
assert_ne!(first, second);
assert_eq!(dao.list_persona_chats(&cx, uid).unwrap().len(), 2);
}
#[test]
fn persona_chat_update_replaces_the_transcript() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p4");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let rows = dao
.update_persona_chat(&cx, uid, &cid, "first", 1, 150)
.unwrap();
assert_eq!(rows, 1);
let rows = dao
.update_persona_chat(&cx, uid, &cid, "second", 2, 200)
.unwrap();
assert_eq!(rows, 1);
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "second"); assert_eq!(row.messages_json, "second");
assert_eq!(row.turn_count, 2); assert_eq!(row.turn_count, 2);
assert_eq!(row.updated_at, 200); assert_eq!(row.updated_at, 200);
// Single rolling transcript → exactly one row per (user, persona). assert_eq!(row.created_at, 100, "creation time is not disturbed");
} }
#[test] #[test]
fn persona_chat_isolation_between_users() { fn persona_chat_update_reports_zero_rows_for_an_unknown_conversation() {
// The turn loop treats 0 as "the conversation went away mid-turn"
// rather than silently succeeding.
let cx = opentelemetry::Context::new(); let cx = opentelemetry::Context::new();
let (mut dao, uid1) = dao_with_user("u1"); let (mut dao, uid) = dao_with_user("p5");
let uid2: i32 = { let rows = dao
let conn = dao.connection.clone(); .update_persona_chat(&cx, uid, "no-such-id", "x", 1, 1)
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(); .unwrap();
u::users assert_eq!(rows, 0);
.filter(u::username.eq("u2")) }
.select(u::id)
.first(conn.lock().unwrap().deref_mut()) #[test]
fn persona_chat_set_title_names_the_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p6");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
dao.set_persona_chat_title(&cx, uid, &cid, "June recap")
.unwrap();
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
assert_eq!(row.title, "June recap");
}
#[test]
fn persona_chat_list_orders_newest_first() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p7");
let older = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let newer = dao.create_persona_chat(&cx, uid, "coach", 300).unwrap();
let middle = dao.create_persona_chat(&cx, uid, "default", 200).unwrap();
let ids: Vec<String> = dao
.list_persona_chats(&cx, uid)
.unwrap() .unwrap()
}; .into_iter()
dao.upsert_persona_chat(&cx, uid1, "default", "u1-row", 1, 1).unwrap(); .map(|r| r.conversation_id)
dao.upsert_persona_chat(&cx, uid2, "default", "u2-row", 1, 2).unwrap(); .collect();
assert_eq!( assert_eq!(
dao.get_persona_chat(&cx, uid1, "default").unwrap().unwrap().messages_json, ids,
"u1-row" vec![newer, middle, older],
); "ordered by updated_at descending so the list screen needs no re-sort"
assert_eq!(
dao.get_persona_chat(&cx, uid2, "default").unwrap().unwrap().messages_json,
"u2-row"
); );
} }
#[test] #[test]
fn persona_chat_clear_wipes_the_row_so_get_returns_none() { fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
let cx = opentelemetry::Context::new(); let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p4"); let (mut dao, uid) = dao_with_user("p8");
dao.upsert_persona_chat(&cx, uid, "default", "[]", 1, 1).unwrap(); assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
dao.clear_persona_chat(&cx, uid, "default").unwrap(); }
assert!(dao.get_persona_chat(&cx, uid, "default").unwrap().is_none());
#[test]
fn persona_chat_delete_removes_the_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p9");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
dao.delete_persona_chat(&cx, uid, &cid).unwrap();
assert!(dao.get_persona_chat(&cx, uid, &cid).unwrap().is_none());
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
}
#[test]
fn persona_chat_reads_and_writes_are_scoped_to_the_owner() {
// A conversation_id is a bearer token for someone's transcript, so
// holding one must not grant another user access to it.
let cx = opentelemetry::Context::new();
let (mut dao, uid1) = dao_with_user("owner");
let uid2 = second_user(&dao, "intruder");
let cid = dao.create_persona_chat(&cx, uid1, "journal", 100).unwrap();
dao.update_persona_chat(&cx, uid1, &cid, "private", 1, 100)
.unwrap();
assert!(
dao.get_persona_chat(&cx, uid2, &cid).unwrap().is_none(),
"another user cannot read it"
);
assert_eq!(
dao.update_persona_chat(&cx, uid2, &cid, "tampered", 9, 999)
.unwrap(),
0,
"another user cannot write it"
);
dao.delete_persona_chat(&cx, uid2, &cid).unwrap();
let row = dao.get_persona_chat(&cx, uid1, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "private", "and cannot delete it");
assert!(dao.list_persona_chats(&cx, uid2).unwrap().is_empty());
} }
} }
+4 -1
View File
@@ -172,11 +172,14 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
persona_chat_conversations (user_id, persona_id) { persona_chat_conversations (conversation_id) {
conversation_id -> Text,
user_id -> Integer, user_id -> Integer,
persona_id -> Text, persona_id -> Text,
title -> Text,
messages_json -> Text, messages_json -> Text,
turn_count -> Integer, turn_count -> Integer,
created_at -> BigInt,
updated_at -> BigInt, updated_at -> BigInt,
} }
} }
+6 -1
View File
@@ -384,9 +384,14 @@ fn main() -> std::io::Result<()> {
.service(ai::cancel_turn_handler) .service(ai::cancel_turn_handler)
.service(ai::persona_chat_history_handler) .service(ai::persona_chat_history_handler)
.service(ai::persona_chat_turn_handler) .service(ai::persona_chat_turn_handler)
.service(ai::persona_chat_reset_handler) .service(ai::persona_chat_create_conversation_handler)
.service(ai::persona_chat_delete_conversation_handler)
.service(ai::persona_turn_replay_handler) .service(ai::persona_turn_replay_handler)
.service(ai::persona_turn_cancel_handler) .service(ai::persona_turn_cancel_handler)
.service(ai::persona_chat_rewind_handler)
.service(ai::persona_chat_switch_branch_handler)
.service(ai::persona_chat_branches_handler)
.service(ai::persona_chat_conversations_handler)
.service(ai::rate_insight_handler) .service(ai::rate_insight_handler)
.service(ai::export_training_data_handler) .service(ai::export_training_data_handler)
.service(ai::tts_speech_handler) .service(ai::tts_speech_handler)
+1 -5
View File
@@ -175,11 +175,7 @@ fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<(
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors /// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales /// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
/// to the large-preview cap instead of 200. /// to the large-preview cap instead of 200.
fn generate_large_preview_ffmpeg( fn generate_large_preview_ffmpeg(src: &Path, dest: &Path, orientation: i32) -> std::io::Result<()> {
src: &Path,
dest: &Path,
orientation: i32,
) -> std::io::Result<()> {
// Rotation + scale + colorspace. HEIC sources use Display P3; without // Rotation + scale + colorspace. HEIC sources use Display P3; without
// colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing // colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing
// warm/oversaturated output. The min(iw,cap) trick caps the long edge // warm/oversaturated output. The min(iw,cap) trick caps the long edge