feat: add /persona_chat endpoints for open chat with persona

- PersonaChatSession dispatches turns through the shared agent loop,
  persisting transcripts keyed on (user_id, persona_id) in the new
  persona_chat table (migration included).
- GET /persona_chat/history returns a rendered transcript (tool
  invocations folded, is_initial flag) matching the file-chat shape.
- POST /persona_chat/turn returns 202 with a turn_id; SSE replay and
  cancel reuse turn_replay_impl/cancel_turn_impl, extracted from the
  actix-attributed handlers so both route families share the logic.
- POST /persona_chat/reset clears the persona transcript.
- Concurrent dispatches for the same (user, persona) are rejected with
  409 via an in-flight gate (InFlightPersonaTurns) whose RAII guard
  drops with the spawned turn task, freeing the slot on completion,
  error, or abort.
This commit is contained in:
Cameron Cordes committed 2026-08-24 21:25:48 -04:00
1 parent 649863a700
commit 3b9d985025
11 files changed
+1716 -179

No files matched your search

+24 -2
View File
@@ -4,6 +4,7 @@ use crate::ai::face_client::FaceClient;
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
use crate::ai::llamacpp::LlamaCppClient;
use crate::ai::openrouter::OpenRouterClient;
use crate::ai::persona_chat::PersonaChatSession;
use crate::ai::turn_registry::TurnRegistry;
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
use crate::database::{
@@ -84,6 +85,9 @@ pub struct AppState {
pub insight_generator: InsightGenerator,
/// Chat continuation service. Hold an Arc so handlers can clone cheaply.
pub insight_chat: Arc<InsightChatService>,
/// Open chat with persona service. Same shape as insight_chat but
/// anchored on (user_id, persona_id) instead of (library_id, file_path).
pub persona_chat_session: Arc<PersonaChatSession>,
pub turn_registry: Arc<TurnRegistry>,
pub face_client: FaceClient,
pub clip_client: ClipClient,
@@ -133,6 +137,7 @@ impl AppState {
sms_client: SmsApiClient,
insight_generator: InsightGenerator,
insight_chat: Arc<InsightChatService>,
persona_chat_session: Arc<PersonaChatSession>,
turn_registry: Arc<TurnRegistry>,
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
face_client: FaceClient,
@@ -194,6 +199,7 @@ impl AppState {
sms_client,
insight_generator,
insight_chat,
persona_chat_session,
turn_registry,
face_client,
clip_client,
@@ -316,7 +322,7 @@ impl Default for AppState {
tag_dao.clone(),
face_dao.clone(),
knowledge_dao,
persona_dao,
persona_dao.clone(),
libraries_vec.clone(),
);
@@ -330,6 +336,13 @@ impl Default for AppState {
chat_locks,
));
// Open chat with persona: reuses the generator + persona DAO.
let persona_chat_session = Arc::new(PersonaChatSession::new(
Arc::new(insight_generator.clone()),
persona_dao.clone(),
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
));
// Turn registry for reconnectable chat turns. 5-minute timeout for
// stale turns (background cleaner drops entries older than this).
let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS")
@@ -360,6 +373,7 @@ impl Default for AppState {
sms_client,
insight_generator,
insight_chat,
persona_chat_session,
turn_registry,
preview_dao,
face_client,
@@ -528,7 +542,7 @@ impl AppState {
tag_dao.clone(),
face_dao.clone(),
knowledge_dao,
persona_dao,
persona_dao.clone(),
vec![test_lib],
);
@@ -540,6 +554,13 @@ impl AppState {
chat_locks,
));
// Open chat with persona (test).
let persona_chat_session = Arc::new(PersonaChatSession::new(
Arc::new(insight_generator.clone()),
persona_dao.clone(),
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
));
// Turn registry for test state.
let turn_registry = Arc::new(TurnRegistry::new(300));
@@ -571,6 +592,7 @@ impl AppState {
sms_client,
insight_generator,
insight_chat,
persona_chat_session,
turn_registry,
preview_dao,
FaceClient::new(None), // disabled in test