feat: add /persona_chat endpoints for open chat with persona
- PersonaChatSession dispatches turns through the shared agent loop, persisting transcripts keyed on (user_id, persona_id) in the new persona_chat table (migration included). - GET /persona_chat/history returns a rendered transcript (tool invocations folded, is_initial flag) matching the file-chat shape. - POST /persona_chat/turn returns 202 with a turn_id; SSE replay and cancel reuse turn_replay_impl/cancel_turn_impl, extracted from the actix-attributed handlers so both route families share the logic. - POST /persona_chat/reset clears the persona transcript. - Concurrent dispatches for the same (user, persona) are rejected with 409 via an in-flight gate (InFlightPersonaTurns) whose RAII guard drops with the spawned turn task, freeing the slot on completion, error, or abort.
This commit is contained in:
1 parent
649863a700
commit
3b9d985025
11 files changed
+1716
-179
No files matched your search
@@ -10,6 +10,19 @@ use crate::database::schema;
|
||||
use crate::database::{DbError, DbErrorKind, connect};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
/// One row of the persona-chat transcript. Lives in
|
||||
/// `persona_chat_conversations` (one row per `(user_id, persona_id)`).
|
||||
///
|
||||
/// `turn_count` is the number of new turn-rows since the last persisted
|
||||
/// slice — used by the SSE `done` event for stats dashboards. The actual
|
||||
/// transcript is the `messages_json` blob.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PersonaChatRow {
|
||||
pub messages_json: String,
|
||||
pub turn_count: i32,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Patch shape for update_persona. None = leave field alone. Built-ins are
|
||||
/// allowed to flip `include_all_memories` but should reject name/prompt
|
||||
/// edits at the handler layer (built-in copy lives in the migration).
|
||||
@@ -80,6 +93,43 @@ pub trait PersonaDao: Sync + Send {
|
||||
user_id: i32,
|
||||
personas: &[ImportPersona],
|
||||
) -> Result<usize, DbError>;
|
||||
|
||||
// ── Persona-chat (open chat with persona) persistence ───────────
|
||||
//
|
||||
// Keyed by `(user_id, persona_id)` with a single rolling transcript
|
||||
// per pair. No tree branching in v1 — matches the locked-in scope.
|
||||
|
||||
/// Fetch the rolling transcript for one `(user, persona)`. None when
|
||||
/// the user has never started a conversation with this persona.
|
||||
fn get_persona_chat(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
) -> Result<Option<PersonaChatRow>, DbError>;
|
||||
|
||||
/// Upsert (create-or-replace) the rolling transcript. Called once per
|
||||
/// completed turn with the full new `messages_json`. The `turn_count`
|
||||
/// is the number of user/assistant pairs added in this write.
|
||||
fn upsert_persona_chat(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
messages_json: &str,
|
||||
turn_count: i32,
|
||||
updated_at: i64,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Wipe the rolling transcript. Reserved for a future "New
|
||||
/// conversation" affordance; the row itself stays (so a subsequent
|
||||
/// get returns None, not 404).
|
||||
fn clear_persona_chat(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
) -> Result<(), DbError>;
|
||||
}
|
||||
|
||||
pub struct SqlitePersonaDao {
|
||||
@@ -296,6 +346,96 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
}
|
||||
|
||||
fn get_persona_chat(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
pid: &str,
|
||||
) -> Result<Option<PersonaChatRow>, DbError> {
|
||||
trace_db_call(cx, "query", "get_persona_chat", |_span| {
|
||||
use schema::persona_chat_conversations::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
persona_chat_conversations
|
||||
.filter(user_id.eq(uid))
|
||||
.filter(persona_id.eq(pid))
|
||||
.select((messages_json, turn_count, updated_at))
|
||||
.first::<(String, i32, i64)>(conn.deref_mut())
|
||||
.optional()
|
||||
.map(|opt| {
|
||||
opt.map(|(m, t, u)| PersonaChatRow {
|
||||
messages_json: m,
|
||||
turn_count: t,
|
||||
updated_at: u,
|
||||
})
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn upsert_persona_chat(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
pid: &str,
|
||||
json: &str,
|
||||
count: i32,
|
||||
updated_at: i64,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(cx, "upsert", "upsert_persona_chat", |_span| {
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
// INSERT OR REPLACE on the (user_id, persona_id) PRIMARY KEY —
|
||||
// single rolling transcript, so a new write always supersedes
|
||||
// the prior one in full. The mobile hook serialises turns with
|
||||
// a per-persona mutex, so this never races. Plain
|
||||
// `sql_query` sidesteps a Diesel type-recursion blow-up that
|
||||
// hits `insert_into(...).on_conflict(...).do_update().set(...)`
|
||||
// with this many typed columns.
|
||||
diesel::sql_query(
|
||||
"INSERT INTO persona_chat_conversations \
|
||||
(user_id, persona_id, messages_json, turn_count, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(user_id, persona_id) DO UPDATE SET \
|
||||
messages_json = excluded.messages_json, \
|
||||
turn_count = excluded.turn_count, \
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind::<diesel::sql_types::Integer, _>(uid)
|
||||
.bind::<diesel::sql_types::Text, _>(pid)
|
||||
.bind::<diesel::sql_types::Text, _>(json)
|
||||
.bind::<diesel::sql_types::Integer, _>(count)
|
||||
.bind::<diesel::sql_types::BigInt, _>(updated_at)
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Upsert error: {}", e))?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
}
|
||||
|
||||
fn clear_persona_chat(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
pid: &str,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(cx, "delete", "clear_persona_chat", |_span| {
|
||||
use schema::persona_chat_conversations::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
// Delete the row entirely so the next get returns None.
|
||||
// The next send will INSERT a fresh seed (system + greeting)
|
||||
// via the same code path a brand-new persona uses.
|
||||
diesel::delete(
|
||||
persona_chat_conversations
|
||||
.filter(user_id.eq(uid))
|
||||
.filter(persona_id.eq(pid)),
|
||||
)
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -444,4 +584,77 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(updated.include_all_memories);
|
||||
}
|
||||
|
||||
// ── Persona-chat DAO tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn persona_chat_get_returns_none_for_never_started() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid) = dao_with_user("p1");
|
||||
let row = dao.get_persona_chat(&cx, uid, "default").unwrap();
|
||||
assert!(row.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_chat_upsert_then_get_round_trip() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid) = dao_with_user("p2");
|
||||
dao.upsert_persona_chat(&cx, uid, "journal", "[]", 1, 100)
|
||||
.unwrap();
|
||||
let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap();
|
||||
assert_eq!(row.messages_json, "[]");
|
||||
assert_eq!(row.turn_count, 1);
|
||||
assert_eq!(row.updated_at, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_chat_upsert_replaces_existing_row() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid) = dao_with_user("p3");
|
||||
dao.upsert_persona_chat(&cx, uid, "journal", "first", 1, 100).unwrap();
|
||||
dao.upsert_persona_chat(&cx, uid, "journal", "second", 2, 200).unwrap();
|
||||
let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap();
|
||||
assert_eq!(row.messages_json, "second");
|
||||
assert_eq!(row.turn_count, 2);
|
||||
assert_eq!(row.updated_at, 200);
|
||||
// Single rolling transcript → exactly one row per (user, persona).
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_chat_isolation_between_users() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid1) = dao_with_user("u1");
|
||||
let uid2: i32 = {
|
||||
let conn = dao.connection.clone();
|
||||
use crate::database::schema::users::dsl as u;
|
||||
diesel::insert_into(u::users)
|
||||
.values((u::username.eq("u2"), u::password.eq("x")))
|
||||
.execute(conn.lock().unwrap().deref_mut())
|
||||
.unwrap();
|
||||
u::users
|
||||
.filter(u::username.eq("u2"))
|
||||
.select(u::id)
|
||||
.first(conn.lock().unwrap().deref_mut())
|
||||
.unwrap()
|
||||
};
|
||||
dao.upsert_persona_chat(&cx, uid1, "default", "u1-row", 1, 1).unwrap();
|
||||
dao.upsert_persona_chat(&cx, uid2, "default", "u2-row", 1, 2).unwrap();
|
||||
assert_eq!(
|
||||
dao.get_persona_chat(&cx, uid1, "default").unwrap().unwrap().messages_json,
|
||||
"u1-row"
|
||||
);
|
||||
assert_eq!(
|
||||
dao.get_persona_chat(&cx, uid2, "default").unwrap().unwrap().messages_json,
|
||||
"u2-row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_chat_clear_wipes_the_row_so_get_returns_none() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid) = dao_with_user("p4");
|
||||
dao.upsert_persona_chat(&cx, uid, "default", "[]", 1, 1).unwrap();
|
||||
dao.clear_persona_chat(&cx, uid, "default").unwrap();
|
||||
assert!(dao.get_persona_chat(&cx, uid, "default").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,16 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
persona_chat_conversations (user_id, persona_id) {
|
||||
user_id -> Integer,
|
||||
persona_id -> Text,
|
||||
messages_json -> Text,
|
||||
turn_count -> Integer,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
personas (id) {
|
||||
id -> Integer,
|
||||
@@ -345,6 +355,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
insight_generation_jobs,
|
||||
libraries,
|
||||
location_history,
|
||||
persona_chat_conversations,
|
||||
personas,
|
||||
persons,
|
||||
photo_insights,
|
||||
|
||||
Reference in new issue
Block a user