Multuser part 1

This commit is contained in:
2026-07-10 22:05:25 +01:00
parent 088c0c01cb
commit c72f18b0d6
15 changed files with 398 additions and 544 deletions
+42 -22
View File
@@ -6,17 +6,20 @@ use axum::{
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
Extension,
};
use serde::Deserialize;
use serde_json::Value;
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
use skald_core::chat_hub::{ModelCommandOutcome, SendMessageOptions};
use skald_core::chat_hub::{ChatHub, ModelCommandOutcome, SendMessageOptions};
use skald_core::events::{ClientMessage, ServerEvent};
use skald_core::skald::Skald;
use core_api::command::CommandApi;
use super::guard::AuthUser;
#[derive(Deserialize)]
pub struct WsParams {
source: Option<String>,
@@ -66,18 +69,35 @@ fn dynamic_help(skald: &Skald) -> String {
// ── Upgrade ───────────────────────────────────────────────────────────────────
pub async fn handler(
ws: WebSocketUpgrade,
Query(params): Query<WsParams>,
State(skald): State<Arc<Skald>>,
ws: WebSocketUpgrade,
Query(params): Query<WsParams>,
Extension(auth): Extension<AuthUser>,
State(skald): State<Arc<Skald>>,
) -> impl IntoResponse {
let source = params.source.unwrap_or_else(|| "web".to_string());
ws.on_upgrade(move |socket| handle_socket(socket, skald, source))
ws.on_upgrade(move |socket| handle_socket(socket, skald, source, auth.user_id))
}
// ── Socket loop ───────────────────────────────────────────────────────────────
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String) {
let session_handler = match skald.chat_hub().session_handler(&source).await {
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String, user_id: String) {
// Resolve the caller's per-user runtime. The pool is unlocked at login, so an
// authenticated connection normally has a context; a missing one means the
// database re-locked (e.g. a restart with no re-login) — report and close.
let ctx = match skald.user_context(&user_id).await {
Some(c) => c,
None => {
let _ = socket.send(to_msg(&ServerEvent::Error {
message: "session expired — please log in again".to_string(),
})).await;
return;
}
};
// Every chat operation for this connection goes through the user's own hub, so
// sessions land in their `{userid}.db` and events never cross to another user.
let chat_hub: Arc<ChatHub> = Arc::clone(&ctx.chat_hub);
let session_handler = match chat_hub.session_handler(&source).await {
Ok(h) => h,
Err(e) => {
let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await;
@@ -85,9 +105,9 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
}
};
info!(source, "WebSocket connected");
info!(source, user = %user_id, "WebSocket connected");
let mut rx = skald.chat_hub().events(&source);
let mut rx = chat_hub.events(&source);
// Tell this (possibly reloaded) client whether a turn is already running for
// its session, so it can restore the STOP button. Sent after subscribing to
@@ -109,7 +129,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
// ── resume ────────────────────────────────────────────────────
if is_resume_msg(&text) {
info!("web WS: resume requested");
let hub = Arc::clone(skald.chat_hub());
let hub = Arc::clone(&chat_hub);
let src = source.clone();
tokio::spawn(async move {
if let Err(e) = hub.resume(&src).await {
@@ -127,10 +147,10 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
session_handler.cancel_pending_questions().await;
continue;
}
if handle_approval_msg(&text, skald.chat_hub()).await { continue; }
if handle_approval_msg(&text, &chat_hub).await { continue; }
if handle_question_answer_msg(&text, &session_handler).await { continue; }
if handle_data_msg(&text, &skald) { continue; }
if handle_select_client_msg(&text, &source, skald.chat_hub()).await { continue; }
if handle_select_client_msg(&text, &source, &chat_hub).await { continue; }
// ── /sethome ──────────────────────────────────────────────────
let client_msg: ClientMessage = match serde_json::from_str(&text) {
@@ -146,7 +166,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
let cmd = client_msg.content.trim();
if cmd == "/sethome" {
let msg = match skald.chat_hub().set_home(&source).await {
let msg = match chat_hub.set_home(&source).await {
Ok(_) => "🏠 Web impostato come **home**. Le notifiche degli agenti arriveranno qui.".to_string(),
Err(e) => format!("⚠️ Errore: {e}"),
};
@@ -172,7 +192,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
}
if cmd == "/context" {
match skald.chat_hub().context_info(&source).await {
match chat_hub.context_info(&source).await {
Ok((input, output)) => {
let input_str = input.map_or("?".to_string(), |t| t.to_string());
let output_str = output.map_or("?".to_string(), |t| t.to_string());
@@ -192,7 +212,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
}
if cmd == "/cost" {
match skald.chat_hub().cost_info(&source).await {
match chat_hub.cost_info(&source).await {
Ok(Some(c)) => {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
@@ -219,7 +239,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
}
if cmd == "/compact" {
match skald.chat_hub().force_compact(&source).await {
match chat_hub.force_compact(&source).await {
Ok(true) => {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
@@ -246,7 +266,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
}
if cmd == "/resettools" {
match skald.chat_hub().reset_mcp(&source).await {
match chat_hub.reset_mcp(&source).await {
Ok(()) => {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
@@ -264,7 +284,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
}
if cmd == "/models" {
let items = skald.chat_hub().list_clients_marked(&source).await;
let items = chat_hub.list_clients_marked(&source).await;
let content = format_models_md(&items);
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
@@ -277,7 +297,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
}
if let Some(arg) = cmd.strip_prefix("/model").map(str::trim) {
let outcome = skald.chat_hub().apply_model_command(&source, arg).await;
let outcome = chat_hub.apply_model_command(&source, arg).await;
let content = match outcome {
ModelCommandOutcome::Set(name) => format!("✅ Model set: **{name}**"),
ModelCommandOutcome::Cleared => "✅ Model reset to **auto**.".to_string(),
@@ -353,14 +373,14 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
// client lives in ChatHub.selected_clients[source]. The web
// `/model` command and the dropdown both flow through
// set_selected_client, which broadcasts ClientSelected.
client_name: skald.chat_hub().get_selected_client(&source).await,
client_name: chat_hub.get_selected_client(&source).await,
extra_system_context: Some(WEB_FORMAT_CONTEXT.to_string()),
// SPA-only tool: lets the assistant open a file in the user's
// viewer. Injected here (not in the registry) so it exists only
// for ws.rs clients (web + mobile), never for the Telegram plugin.
interface_tools: vec![
skald_core::tools::show_file::make_tool(
Arc::clone(skald.chat_hub()),
Arc::clone(&chat_hub),
source.clone(),
),
],
@@ -368,7 +388,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
};
// send_message only enqueues — the turn runs on ChatHub's per-source
// consumer — so awaiting inline keeps this WS read loop responsive.
if let Err(e) = skald.chat_hub().send_message(&source, &content, opts).await {
if let Err(e) = chat_hub.send_message(&source, &content, opts).await {
tracing::error!(error = %e, source = %source, "send_message enqueue failed");
}
}
+17 -7
View File
@@ -6,24 +6,34 @@ use axum::{
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
Extension,
};
use tokio::sync::broadcast;
use tracing::{info, warn};
use skald_core::skald::Skald;
use super::guard::AuthUser;
pub async fn handler(
ws: WebSocketUpgrade,
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>,
ws: WebSocketUpgrade,
Path(id): Path<i64>,
Extension(auth): Extension<AuthUser>,
State(skald): State<Arc<Skald>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, skald, id))
ws.on_upgrade(move |socket| handle_socket(socket, skald, id, auth.user_id))
}
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, session_id: i64) {
info!(session_id, "session-watch WS connected");
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, session_id: i64, user_id: String) {
info!(session_id, user = %user_id, "session-watch WS connected");
let mut rx = skald.chat_hub().events("session-watch");
// Watch this user's own event stream, so a session-watch only ever sees events
// for sessions in the watcher's `{userid}.db`.
let ctx = match skald.user_context(&user_id).await {
Some(c) => c,
None => return,
};
let mut rx = ctx.chat_hub.events("session-watch");
loop {
tokio::select! {
+4 -3
View File
@@ -164,15 +164,16 @@ pub async fn run_backend() -> Result<Backend> {
/// Build the plugin list. Extracted so both entry points share the same set.
fn build_plugins() -> Vec<Arc<dyn Plugin>> {
// NOTE (multi-user slice): telegram-bot, mobile-connector and honcho are
// single-user / global-ChatEventBus-subscriber plugins. They assume one user
// and cannot work under per-user isolation yet, so they are dropped from the
// build until they become multi-user-aware. See blueprint §17 and the plan.
let mut plugins: Vec<Arc<dyn Plugin>> = vec![
Arc::new(plugin_honcho::HonchoPlugin::new()),
Arc::new(plugin_telegram_bot::TelegramPlugin::new("secrets")),
Arc::new(plugin_tailscale_remote::RemotePlugin::new()),
Arc::new(plugin_comfyui::ComfyUIPlugin::new()),
Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()),
Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()),
Arc::new(plugin_elevenlabs::ElevenLabsPlugin::new()),
Arc::new(plugin_mobile_connector::MobileConnectorPlugin::new()),
];
#[cfg(feature = "whisper-local")]
plugins.push(Arc::new(plugin_transcribe_whisper_local::WhisperLocalPlugin::new()));