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
+56
-10
@@ -1819,6 +1819,19 @@ pub async fn turn_replay_handler(
|
||||
path: web::Path<String>,
|
||||
query: web::Query<ReplayQuery>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
turn_replay_impl(http_request, path, query, app_state).await
|
||||
}
|
||||
|
||||
/// Core of the SSE replay, kept attribute-free so the persona-chat routes
|
||||
/// can reuse it under `/persona_chat/turn/{turn_id}`. The registry is keyed
|
||||
/// on `turn_id` only, and `render_turn_info_frame` already scopes the
|
||||
/// identity (persona turns carry `persona_id` and no `file_path`).
|
||||
pub(crate) async fn turn_replay_impl(
|
||||
http_request: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
query: web::Query<ReplayQuery>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
use crate::ai::turn_registry::ReplayOutcome;
|
||||
|
||||
@@ -1949,15 +1962,31 @@ pub async fn turn_replay_handler(
|
||||
}
|
||||
|
||||
fn render_turn_info_frame(info: &crate::ai::turn_registry::TurnInfo) -> String {
|
||||
let payload = serde_json::json!({
|
||||
"turn_id": info.turn_id,
|
||||
"file_path": info.file_path,
|
||||
"library_id": info.library_id,
|
||||
"status": info.status.as_str(),
|
||||
"total_events_pushed": info.total_events_pushed,
|
||||
"buffered_count": info.buffered_count,
|
||||
});
|
||||
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
|
||||
// Persona-scoped turns leak no `file_path` to the client — the open
|
||||
// chat is keyed on `(persona_id)` only and a stray path echo from a
|
||||
// quoted SMS would be a privacy regression. Insight-scoped turns
|
||||
// include the path as before.
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert("turn_id".into(), serde_json::json!(info.turn_id));
|
||||
payload.insert("library_id".into(), serde_json::json!(info.library_id));
|
||||
payload.insert("scope".into(), serde_json::json!(info.scope));
|
||||
payload.insert("status".into(), serde_json::json!(info.status.as_str()));
|
||||
payload.insert(
|
||||
"total_events_pushed".into(),
|
||||
serde_json::json!(info.total_events_pushed),
|
||||
);
|
||||
payload.insert(
|
||||
"buffered_count".into(),
|
||||
serde_json::json!(info.buffered_count),
|
||||
);
|
||||
if info.scope == "insight" {
|
||||
payload.insert("file_path".into(), serde_json::json!(info.file_path));
|
||||
}
|
||||
if let Some(ref pid) = info.persona_id {
|
||||
payload.insert("persona_id".into(), serde_json::json!(pid));
|
||||
}
|
||||
let data = serde_json::to_string(&serde_json::Value::Object(payload))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
format!("event: turn_info\ndata: {}\n\n", data)
|
||||
}
|
||||
|
||||
@@ -1967,6 +1996,16 @@ pub async fn cancel_turn_handler(
|
||||
http_request: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
cancel_turn_impl(http_request, path, app_state).await
|
||||
}
|
||||
|
||||
/// Core of the turn-cancel, attribute-free so the persona-chat DELETE route
|
||||
/// can reuse it under `/persona_chat/turn/{turn_id}`.
|
||||
pub(crate) async fn cancel_turn_impl(
|
||||
http_request: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
let turn_id = path.into_inner();
|
||||
|
||||
@@ -2012,11 +2051,18 @@ pub async fn cancel_turn_handler(
|
||||
entry.set_terminal_status(crate::ai::turn_registry::TurnStatus::Cancelled);
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"cancelled": true
|
||||
}))
|
||||
}
|
||||
|
||||
// Persona-chat handlers (`/persona_chat/*`) live in `ai::persona_chat` and
|
||||
// are registered in main.rs. The persona SSE replay and cancel routes are
|
||||
// thin wrappers over `turn_replay_handler` + `cancel_turn_handler`: both
|
||||
// are keyed on `turn_id`, not file_path, and `TurnInfo::scope` ("persona")
|
||||
// lets the shared `turn_info` frame carry the right identity (persona_id,
|
||||
// no file_path).
|
||||
|
||||
#[cfg(test)]
|
||||
mod turn_replay_tests {
|
||||
use super::{cancel_turn_handler, render_indexed_frame, turn_replay_handler};
|
||||
|
||||
Reference in new issue
Block a user