First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
use axum::{Json, extract::State, response::IntoResponse};
use serde::Serialize;
use crate::core::agents::AgentMeta;
use crate::core::llm::{LlmModelInfo, sort_models_for_agent};
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
pub async fn list(_: State<Arc<Skald>>) -> Result<Json<Vec<AgentMeta>>, ApiError> {
let agents = crate::core::agents::discover()?;
Ok(Json(agents))
}
#[derive(Serialize)]
pub struct AgentDetail {
pub meta: AgentMeta,
pub prompt: String,
pub models: Vec<LlmModelInfo>,
}
pub async fn get(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Result<Json<AgentDetail>, ApiError> {
let meta = crate::core::agents::load_meta(&id)?;
let prompt = crate::core::agents::load_prompt(&id)?;
let all = skald.manager().llm_manager().list_models_info().await;
let models = sort_models_for_agent(all, meta.scope.as_deref(), meta.strength);
Ok(Json(AgentDetail { meta, prompt, models }))
}
/// Serve the agent's icon image file (e.g. icon.png) from `agents/{id}/<icon_path>`.
pub async fn icon(
axum::extract::Path(id): axum::extract::Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let meta = crate::core::agents::load_meta(&id)?;
let icon_path = meta.icon.ok_or_else(|| {
ApiError::not_found(format!("Agent '{}' has no icon configured", id))
})?;
let full_path = format!("agents/{id}/{icon_path}");
let data = tokio::fs::read(&full_path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ApiError::not_found(format!("Icon file not found: {full_path}"))
} else {
ApiError::from(e)
}
})?;
// Determine content type based on extension
let content_type = if full_path.ends_with(".svg") {
"image/svg+xml"
} else if full_path.ends_with(".png") {
"image/png"
} else if full_path.ends_with(".jpg") || full_path.ends_with(".jpeg") {
"image/jpeg"
} else if full_path.ends_with(".webp") {
"image/webp"
} else {
"application/octet-stream"
};
Ok((
[("Content-Type", content_type)],
data,
))
}
+144
View File
@@ -0,0 +1,144 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::core::approval::NewApprovalRule;
use crate::core::tool_catalog::{AllTools, McpServerMeta, ToolInfo};
use std::collections::HashSet;
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
// ── GET /api/approval/rules ───────────────────────────────────────────────────
pub async fn list_rules(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Value>, ApiError> {
let rules = skald.approval().list_rules().await?;
Ok(Json(json!(rules)))
}
// ── POST /api/approval/rules ──────────────────────────────────────────────────
pub async fn create_rule(
State(skald): State<Arc<Skald>>,
Json(body): Json<NewApprovalRule>,
) -> Result<Json<Value>, ApiError> {
let id = skald.approval().add_rule(body).await?;
Ok(Json(json!({ "id": id })))
}
// ── PUT /api/approval/rules/:id ───────────────────────────────────────────────
#[derive(Deserialize)]
pub struct RulePath { pub id: i64 }
pub async fn update_rule(
State(skald): State<Arc<Skald>>,
Path(p): Path<RulePath>,
Json(body): Json<NewApprovalRule>,
) -> Result<Json<Value>, ApiError> {
skald.approval().update_rule(p.id, body).await?;
Ok(Json(json!({ "ok": true })))
}
// ── DELETE /api/approval/rules/:id ────────────────────────────────────────────
pub async fn delete_rule(
State(skald): State<Arc<Skald>>,
Path(p): Path<RulePath>,
) -> Result<Json<Value>, ApiError> {
skald.approval().delete_rule(p.id).await?;
Ok(Json(json!({ "ok": true })))
}
// ── POST /api/approval/pending/:request_id/resolve ───────────────────────────
//
// Resolve a pending approval by request_id, regardless of which session or
// source it belongs to. Useful for Telegram sub-agent approvals when the
// Telegram keyboard is unavailable.
#[derive(Deserialize)]
pub struct ResolvePath { pub request_id: i64 }
#[derive(Deserialize)]
pub struct ResolveBody {
/// "approve" (default) or "reject".
#[serde(default = "default_action")]
pub action: String,
#[serde(default)]
pub note: String,
}
fn default_action() -> String { "approve".to_string() }
pub async fn resolve_pending(
State(skald): State<Arc<Skald>>,
Path(p): Path<ResolvePath>,
Json(body): Json<ResolveBody>,
) -> Result<Json<Value>, ApiError> {
if body.action == "reject" {
// Pass the raw note; the waiting session builds the canonical message.
skald.inbox().reject(p.request_id, body.note.clone()).await;
} else {
skald.inbox().approve(p.request_id).await;
}
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action })))
}
// ── GET /api/approval/pending ─────────────────────────────────────────────────
//
// Returns all currently-pending approval requests (all sessions).
pub async fn list_pending(
State(skald): State<Arc<Skald>>,
) -> Json<Value> {
let pending = skald.inbox().list_pending().await.approvals;
Json(json!(pending))
}
// ── GET /api/approval/tools ───────────────────────────────────────────────────
//
// Returns all available tools (built-in + MCP) so the frontend can show a
// picker with names and descriptions when creating approval rules.
pub async fn list_tools(
State(skald): State<Arc<Skald>>,
) -> Result<Json<AllTools>, ApiError> {
let mut tools = skald.catalog().list_all();
let server_rows = crate::core::db::mcp_servers::all(skald.db()).await?;
tools.mcp_servers = server_rows.into_iter()
.map(|r| (r.name, McpServerMeta { friendly_name: r.friendly_name, description: r.description }))
.collect();
// Merge dynamically-discovered tools (recorded by `ToolDiscovery` when they
// were offered to the LLM) that the catalog does not already surface — the
// interface/plugin/provider tools injected outside the `ToolRegistry`. This
// is what makes them configurable in the Security-groups grid. Names already
// known as built-in or MCP tools are deduped out; the rest are grouped under
// the "dynamic" category.
let discovered = crate::core::db::known_tools::all(skald.db()).await?;
let existing: HashSet<&str> = tools.built_in.iter()
.chain(tools.mcp.iter())
.map(|t| t.name.as_str())
.collect();
let mut extra: Vec<ToolInfo> = discovered.into_iter()
.filter(|k| !existing.contains(k.name.as_str()))
.map(|k| ToolInfo {
name: k.name,
description: k.description,
source: "built-in".into(),
server: None,
category: Some("dynamic".into()),
})
.collect();
drop(existing);
tools.built_in.append(&mut extra);
tools.built_in.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Json(tools))
}
+14
View File
@@ -0,0 +1,14 @@
use std::sync::Arc;
use axum::{Json, extract::State};
use core_api::command::{CommandApi, CommandInfo};
use crate::core::skald::Skald;
/// `GET /api/commands` — list enabled custom slash commands (name + description)
/// for the composer autocomplete and the dynamic `/help`. Read-only: commands are
/// created by adding files under `commands/<name>/` (like agents), not via the API.
pub async fn list(State(skald): State<Arc<Skald>>) -> Json<Vec<CommandInfo>> {
Json(skald.command_manager().list_enabled())
}
+127
View File
@@ -0,0 +1,127 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use core_api::PropertyType;
use core_api::system_bus::SystemEvent;
use crate::core::skald::Skald;
use super::ApiError;
// ── Response types ─────────────────────────────────────────────────────────────
#[derive(Serialize, Clone)]
struct SecurityGroupOption {
id: String,
name: String,
}
#[derive(Serialize)]
struct PropertyView {
key: String,
name: String,
description: String,
property_type: String,
value: Option<String>,
default_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<Vec<SecurityGroupOption>>,
}
#[derive(Serialize)]
struct ConfigSetView {
name: String,
description: String,
properties: Vec<PropertyView>,
}
// ── GET /api/config ────────────────────────────────────────────────────────────
pub async fn list_properties(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Value>, ApiError> {
let security_groups = skald.run_context_manager().list_groups().await
.unwrap_or_default()
.into_iter()
.map(|g| SecurityGroupOption { id: g.id, name: g.name })
.collect::<Vec<_>>();
let mut sets = Vec::with_capacity(skald.config_properties().len());
for set in skald.config_properties() {
let mut props = Vec::with_capacity(set.properties.len());
for prop in &set.properties {
let value = skald.config().get(&prop.key).await?;
let (type_str, options) = match prop.property_type {
PropertyType::Int => ("int", None),
PropertyType::Bool => ("bool", None),
PropertyType::String => ("string", None),
PropertyType::SecurityGroup => ("security_group", Some(security_groups.clone())),
};
props.push(PropertyView {
key: prop.key.clone(),
name: prop.name.clone(),
description: prop.description.clone(),
property_type: type_str.into(),
value,
default_value: prop.default_value.clone(),
options,
});
}
sets.push(ConfigSetView {
name: set.name.clone(),
description: set.description.clone(),
properties: props,
});
}
Ok(Json(json!({ "sets": sets })))
}
// ── PUT /api/config/:key ────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct SetPropertyBody {
pub value: String,
}
#[derive(Deserialize)]
pub struct KeyPath {
pub key: String,
}
pub async fn set_property(
State(skald): State<Arc<Skald>>,
Path(p): Path<KeyPath>,
Json(body): Json<SetPropertyBody>,
) -> Result<StatusCode, ApiError> {
// Only allow keys that are registered as config properties.
let known = skald.config_properties().iter()
.flat_map(|s| &s.properties)
.any(|prop| prop.key == p.key);
if !known {
return Err(ApiError::not_found("unknown config key"));
}
let old_value = skald.config().get(&p.key).await?;
// No-op if value didn't change.
if old_value.as_deref() == Some(body.value.as_str()) {
return Ok(StatusCode::OK);
}
skald.config().set(&p.key, &body.value).await?;
skald.system_bus().send(SystemEvent::ConfigKeyUpdated {
key: p.key.clone(),
old_value,
new_value: body.value,
});
Ok(StatusCode::OK)
}
+137
View File
@@ -0,0 +1,137 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use serde::Deserialize;
use crate::core::db::{scheduled_jobs, job_runs};
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
#[derive(serde::Serialize)]
pub struct JobResponse {
pub id: i64,
pub title: String,
pub description: String,
pub cron: String,
pub prompt: String,
pub agent_id: String,
pub enabled: bool,
pub single_run: bool,
pub kind: String,
pub last_run_at: Option<String>,
pub next_run_at: Option<String>,
pub created_at: String,
pub run_context: Option<String>,
pub running_session_id: Option<i64>,
pub running_since: Option<String>,
}
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<JobResponse>>, ApiError> {
let jobs = scheduled_jobs::list(skald.db()).await?;
Ok(Json(jobs.into_iter().map(|j| JobResponse {
id: j.id,
title: j.title,
description: j.description,
cron: j.cron,
prompt: j.prompt,
agent_id: j.agent_id,
enabled: j.enabled,
single_run: j.single_run,
kind: j.kind,
last_run_at: j.last_run_at,
next_run_at: j.next_run_at,
created_at: j.created_at,
run_context: j.run_context,
running_session_id: j.running_session_id,
running_since: j.running_since,
}).collect()))
}
pub async fn delete_job(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>,
) -> Result<(), ApiError> {
let found = scheduled_jobs::delete(skald.db(), id).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
}
pub async fn toggle(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>,
Json(body): Json<serde_json::Value>,
) -> Result<(), ApiError> {
let enabled = body["enabled"]
.as_bool()
.ok_or_else(|| ApiError::bad_request("'enabled' boolean required"))?;
let found = scheduled_jobs::set_enabled(skald.db(), id, enabled).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
}
#[derive(Deserialize)]
pub struct SetRunContextBody {
pub security_group: Option<String>,
}
pub async fn set_run_context(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>,
Json(body): Json<SetRunContextBody>,
) -> Result<(), ApiError> {
use crate::core::run_context::RunContext;
let json = body.security_group.as_ref().map(|sg| {
RunContext::with_security_group(Some(sg.clone())).to_db()
});
let found = scheduled_jobs::set_run_context(skald.db(), id, json.as_deref()).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
}
#[derive(serde::Serialize)]
pub struct JobRunResponse {
pub id: i64,
pub job_id: i64,
pub job_title: Option<String>,
pub agent_id: Option<String>,
pub kind: Option<String>,
pub session_id: Option<i64>,
pub started_at: String,
pub completed_at: Option<String>,
pub duration_ms: Option<i64>,
pub status: String,
pub final_response: Option<String>,
pub error: Option<String>,
pub created_at: String,
}
pub async fn kill_job(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>,
) -> Result<StatusCode, ApiError> {
let job = scheduled_jobs::get_by_id(skald.db(), id).await?
.ok_or_else(|| ApiError::not_found(format!("job {id} not found")))?;
let session_id = job.running_session_id
.ok_or_else(|| ApiError::bad_request("job is not currently running"))?;
skald.system_bus().send(core_api::system_bus::SystemEvent::SessionCancelled { session_id });
Ok(StatusCode::ACCEPTED)
}
pub async fn list_runs(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<JobRunResponse>>, ApiError> {
let runs = job_runs::list_all(skald.db(), 200).await?;
Ok(Json(runs.into_iter().map(|r| JobRunResponse {
id: r.id,
job_id: r.job_id,
job_title: r.job_title,
agent_id: r.agent_id,
kind: r.kind,
session_id: r.session_id,
started_at: r.started_at,
completed_at: r.completed_at,
duration_ms: r.duration_ms,
status: r.status,
final_response: r.final_response,
error: r.error,
created_at: r.created_at,
}).collect()))
}
+203
View File
@@ -0,0 +1,203 @@
use axum::{
extract::{Path, Query, State},
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
const KEY: &str = "DEBUG_MODE";
#[derive(Serialize)]
pub struct DebugModeResponse {
pub enabled: bool,
}
#[derive(Deserialize)]
pub struct DebugModeBody {
pub enabled: bool,
}
pub async fn get_debug_mode(
State(skald): State<Arc<Skald>>,
) -> Result<impl IntoResponse, ApiError> {
let value = skald.config().get(KEY).await?;
let enabled = value.as_deref() == Some("true");
Ok(Json(DebugModeResponse { enabled }))
}
pub async fn set_debug_mode(
State(skald): State<Arc<Skald>>,
Json(body): Json<DebugModeBody>,
) -> Result<impl IntoResponse, ApiError> {
let value = if body.enabled { "true" } else { "false" };
skald.config().set(KEY, value).await?;
Ok(Json(DebugModeResponse { enabled: body.enabled }))
}
// ── LLM requests log ─────────────────────────────────────────────────────────
const PAGE_SIZE: i64 = 20;
#[derive(Deserialize)]
pub struct LlmRequestsQuery {
pub agent_id: Option<String>,
pub source: Option<String>,
pub from: Option<String>,
pub to: Option<String>,
pub page: Option<i64>,
}
#[derive(Serialize)]
pub struct LlmRequestItem {
pub id: i64,
pub agent_id: Option<String>,
pub source: Option<String>,
pub model_name: String,
pub created_at: String,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub cache_read_tokens: Option<i64>,
pub cache_creation_tokens: Option<i64>,
pub duration_ms: i64,
pub error_text: Option<String>,
}
#[derive(Serialize)]
pub struct LlmRequestsResponse {
pub items: Vec<LlmRequestItem>,
pub total: i64,
pub page: i64,
pub page_size: i64,
}
pub async fn list_llm_requests(
State(skald): State<Arc<Skald>>,
Query(params): Query<LlmRequestsQuery>,
) -> Result<impl IntoResponse, ApiError> {
let page = params.page.unwrap_or(1).max(1);
let offset = (page - 1) * PAGE_SIZE;
// Bind optional filters twice each: once for the IS NULL check, once for the
// equality check. SQLite evaluates `? IS NULL` against the bound value itself.
let items = sqlx::query_as::<_, (i64, Option<String>, Option<String>, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>)>(
"SELECT
r.id,
s.agent_id,
s.source,
r.model_name,
r.created_at,
r.input_tokens,
r.output_tokens,
r.cache_read_tokens,
r.cache_creation_tokens,
r.duration_ms,
r.error_text
FROM llm_requests r
LEFT JOIN chat_sessions s ON s.id = r.session_id
WHERE (? IS NULL OR s.agent_id = ?)
AND (? IS NULL OR s.source = ?)
AND (? IS NULL OR r.created_at >= ?)
AND (? IS NULL OR r.created_at <= ?)
ORDER BY r.created_at DESC
LIMIT ? OFFSET ?",
)
.bind(&params.agent_id).bind(&params.agent_id)
.bind(&params.source).bind(&params.source)
.bind(&params.from).bind(&params.from)
.bind(&params.to).bind(&params.to)
.bind(PAGE_SIZE)
.bind(offset)
.fetch_all(&**skald.db())
.await?
.into_iter()
.map(|(id, agent_id, source, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text)| {
LlmRequestItem { id, agent_id, source, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text }
})
.collect::<Vec<_>>();
let total = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*)
FROM llm_requests r
LEFT JOIN chat_sessions s ON s.id = r.session_id
WHERE (? IS NULL OR s.agent_id = ?)
AND (? IS NULL OR s.source = ?)
AND (? IS NULL OR r.created_at >= ?)
AND (? IS NULL OR r.created_at <= ?)",
)
.bind(&params.agent_id).bind(&params.agent_id)
.bind(&params.source).bind(&params.source)
.bind(&params.from).bind(&params.from)
.bind(&params.to).bind(&params.to)
.fetch_one(&**skald.db())
.await?;
Ok(Json(LlmRequestsResponse { items, total, page, page_size: PAGE_SIZE }))
}
// ── LLM request detail ────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct LlmRequestDetail {
pub id: i64,
pub agent_id: Option<String>,
pub source: Option<String>,
pub stack_id: Option<i64>,
pub model_name: String,
pub created_at: String,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub cache_read_tokens: Option<i64>,
pub cache_creation_tokens: Option<i64>,
pub duration_ms: i64,
pub error_text: Option<String>,
pub request_json: Option<String>,
pub request_headers: Option<String>,
pub response_json: Option<String>,
pub response_headers: Option<String>,
}
pub async fn get_llm_request(
State(skald): State<Arc<Skald>>,
Path(id): Path<i64>,
) -> Result<impl IntoResponse, ApiError> {
let row = sqlx::query_as::<_, (i64, Option<String>, Option<String>, Option<i64>, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
"SELECT
r.id,
s.agent_id,
s.source,
r.stack_id,
r.model_name,
r.created_at,
r.input_tokens,
r.output_tokens,
r.cache_read_tokens,
r.cache_creation_tokens,
r.duration_ms,
r.error_text,
NULLIF(r.request_json, '') AS request_json,
r.request_headers,
r.response_json,
r.response_headers
FROM llm_requests r
LEFT JOIN chat_sessions s ON s.id = r.session_id
WHERE r.id = ?",
)
.bind(id)
.fetch_optional(&**skald.db())
.await?;
let Some((id, agent_id, source, stack_id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text, request_json, request_headers, response_json, response_headers)) = row else {
return Err(ApiError::not_found(format!("llm_request {id} not found")));
};
Ok(Json(LlmRequestDetail {
id, agent_id, source, stack_id, model_name, created_at,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
duration_ms, error_text,
request_json, request_headers, response_json, response_headers,
}))
}
+241
View File
@@ -0,0 +1,241 @@
//! File-watch WebSocket endpoint.
//!
//! `GET /api/file/watch` upgrades to a long-lived WebSocket. The client sends
//! JSON commands:
//!
//! ```jsonc
//! { "op": "subscribe", "path": "docs/index.md" } // start watching
//! { "op": "unsubscribe", "path": "docs/index.md" } // stop watching
//! ```
//!
//! The server pushes change notifications:
//!
//! ```jsonc
//! { "type": "subscribed", "path": "..." } // ack after a successful subscribe
//! { "type": "unsubscribed", "path": "..." } // ack after an unsubscribe
//! { "type": "changed", "path": "..." } // file changed on disk
//! { "type": "error", "path": "...", "error": "..." } // watch install failed
//! ```
//!
//! `path` is the original user-supplied string (relative or absolute) — it
//! round-trips unchanged so the client can match it against the path it asked
//! to watch. The backend resolves it to an absolute path via `fs_tools::resolve`
//! (same path model as `GET /api/file`), so absolute paths are used as-is and
//! relative paths resolve against Skald's process CWD (the data root).
//!
//! One OS watcher per watched file per connection (no cross-connection
//! sharing). On disconnect every watcher is dropped and the OS resources are
//! released automatically.
//!
//! ## LaTeX dependency-aware watching
//!
//! When subscribing to a `.tex` / `.latex` source, the server expands the
//! single path into the full dependency set discovered via the `LatexCompiler`'s
//! `.fls` sidecar (every `\input`'ed fragment, custom `.sty` / `.cls`,
//! `.bib`, images, etc.). One OS watcher is installed per dependency. Any
//! change to any of them is forwarded to the client as a `changed` event for
//! the original `.tex` path — so the file viewer does not need to know about
//! the dependency graph.
//!
//! The dependency set is re-synced whenever the main `.tex` changes (or any of
//! its dependencies does): the watchers for that path are dropped and
//! re-installed with the fresh `.fls` content, so newly-added `\input`s are
//! picked up automatically. On the very first subscribe, when no compile has
//! happened yet, only the main `.tex` itself is watched; once the viewer's
//! first compile writes the `.fls`, the next change event triggers the re-sync.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::{
extract::{
State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
};
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Deserialize;
use serde_json::json;
use tokio::sync::mpsc;
use tracing::info;
use crate::core::skald::Skald;
use crate::core::tools::fs as fs_tools;
pub async fn handler(
ws: WebSocketUpgrade,
State(skald): State<Arc<Skald>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, skald))
}
#[derive(Deserialize)]
struct ClientMsg {
op: String,
path: String,
}
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>) {
info!("file-watch WS connected");
// Single mpsc into which every watcher callback forwards via an unbounded
// sender (unbounded so the sync callback never blocks).
let (change_tx, mut change_rx) = mpsc::unbounded_channel::<String>();
// original_path -> watchers (dropping the vec un-watches every path).
// A vec per subscription because LaTeX sources expand to one watcher per
// dependency.
let mut watchers: HashMap<String, Vec<RecommendedWatcher>> = HashMap::new();
loop {
tokio::select! {
msg = socket.recv() => {
match msg {
Some(Ok(Message::Text(text))) => {
let parsed = match serde_json::from_str::<ClientMsg>(&text) {
Ok(p) => p,
Err(e) => {
let _ = send_json(&mut socket,
json!({ "type": "error", "error": format!("bad message: {e}") })
).await;
continue;
}
};
match parsed.op.as_str() {
"subscribe" => {
if watchers.contains_key(&parsed.path) {
// Already subscribed — silently ack.
let _ = send_json(&mut socket,
json!({ "type": "subscribed", "path": parsed.path })
).await;
continue;
}
match install_watcher(&parsed.path, &change_tx, &mut watchers, &skald) {
Ok(()) => {
let _ = send_json(&mut socket,
json!({ "type": "subscribed", "path": parsed.path })
).await;
}
Err(err) => {
let _ = send_json(&mut socket,
json!({ "type": "error", "path": parsed.path, "error": err })
).await;
}
}
}
"unsubscribe" => {
if watchers.remove(&parsed.path).is_some() {
let _ = send_json(&mut socket,
json!({ "type": "unsubscribed", "path": parsed.path })
).await;
}
}
other => {
let _ = send_json(&mut socket,
json!({ "type": "error", "error": format!("unknown op: {other}") })
).await;
}
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
changed_path = change_rx.recv() => {
if let Some(p) = changed_path {
if send_json(&mut socket, json!({ "type": "changed", "path": p })).await.is_err() {
break;
}
// For LaTeX sources, the dependency set may have changed
// (e.g. a new \input was added, or the first compile just
// wrote the .fls). Drop & re-install the watchers for that
// path so they reflect the current dependency graph.
if is_latex_path(&p) {
if watchers.remove(&p).is_some() {
let _ = install_watcher(&p, &change_tx, &mut watchers, &skald);
}
}
}
}
}
}
info!("file-watch WS disconnected");
}
/// Create one `RecommendedWatcher` per watched path and store them in
/// `watchers` keyed by the original (un-resolved) `user_path`. Returns an
/// error string on failure so the caller can report it to the client.
///
/// For `.tex` / `.latex` sources the single user path is expanded into the
/// full dependency set via `LatexCompiler::watch_paths_for` (every
/// `\input`'ed file, custom `.sty`/`.cls`, `.bib`, images, etc.). All events
/// for any dependency are forwarded to the client as a `changed` event for
/// the original `.tex` path.
fn install_watcher(
user_path: &str,
change_tx: &mpsc::UnboundedSender<String>,
watchers: &mut HashMap<String, Vec<RecommendedWatcher>>,
skald: &Skald,
) -> Result<(), String> {
let abs: PathBuf = fs_tools::resolve(user_path).map_err(|e| e.to_string())?;
let paths_to_watch: Vec<PathBuf> = if is_latex_path(user_path) {
skald.latex_compiler().watch_paths_for(&abs)
} else {
vec![abs]
};
let mut installed: Vec<RecommendedWatcher> = Vec::with_capacity(paths_to_watch.len());
for path in paths_to_watch {
let tx_for_cb = change_tx.clone();
let original_path = user_path.to_string();
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<notify::Event>| {
// Any event on the watched path triggers a change notification.
// We don't inspect the event kind — reload on the client side
// re-reads the file and naturally handles create/modify/remove.
if res.is_ok() {
if tx_for_cb.send(original_path.clone()).is_err() {
// channel closed — receiver dropped (WS disconnected).
}
}
},
Config::default(),
)
.map_err(|e| format!("watcher create failed: {e}"))?;
// Skip non-existent paths gracefully: a dependency may have been
// removed since the .fls was last written. The next compile will
// refresh the .fls and the watcher set will be re-synced.
if path.exists() {
watcher
.watch(&path, RecursiveMode::NonRecursive)
.map_err(|e| format!("watch install failed for {}: {e}", path.display()))?;
}
installed.push(watcher);
}
watchers.insert(user_path.to_string(), installed);
Ok(())
}
/// True for `.tex` / `.latex` extensions — sources that trigger the
/// dependency-aware watcher expansion.
fn is_latex_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| matches!(e.to_ascii_lowercase().as_str(), "tex" | "latex"))
.unwrap_or(false)
}
async fn send_json(socket: &mut WebSocket, value: serde_json::Value) -> Result<(), axum::Error> {
socket.send(Message::Text(value.to_string().into())).await
}
+301
View File
@@ -0,0 +1,301 @@
use std::path::Path;
use axum::{
Json,
extract::{Query, State},
http::{HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::core::skald::Skald;
use crate::core::latex::CompileError;
use crate::core::tools::fs as fs_tools;
use super::ApiError;
#[derive(Serialize)]
pub struct FileEntry {
pub path: String,
pub name: String,
}
pub async fn list_files(State(_state): State<Arc<Skald>>) -> Result<Json<Vec<FileEntry>>, ApiError> {
let root = fs_tools::resolve(".")?;
let mut paths: Vec<String> = Vec::new();
walk(&root, &root, &mut paths)?;
paths.sort();
let entries = paths
.into_iter()
.map(|p| {
let name = Path::new(&p)
.file_stem()
.map_or_else(|| p.clone(), |s| s.to_string_lossy().to_string());
FileEntry { path: p, name }
})
.collect();
Ok(Json(entries))
}
#[derive(Deserialize)]
pub struct FileQuery {
pub path: String,
/// When `true` and `path` points at a `.tex` / `.latex` file, compile it
/// to PDF via `latexmk` and return the PDF bytes instead of the raw
/// source. Other file types ignore this flag.
#[serde(rename = "compile-latex", default)]
pub compile_latex: bool,
/// When `true`, mark the response as a download (`Content-Disposition:
/// attachment`) so the browser saves the file instead of rendering it
/// inline. For a compiled `.tex` the attachment name is `<stem>.pdf`.
#[serde(rename = "force_download", default)]
pub force_download: bool,
}
/// Serve a file's raw bytes with a `Content-Type` derived from its extension.
///
/// Raw bytes (not `read_to_string`) so binary formats — images, PDFs — work; the
/// frontend file viewer reads text via `res.text()` and binaries via `res.blob()`.
///
/// With `?compile-latex=true` a `.tex` source is compiled to PDF (see
/// [`crate::core::latex::LatexCompiler`]); the response is then
/// `application/pdf`. Compilation failures yield `422 Unprocessable Entity`
/// with the textual `latexmk` log in the body, so the caller can fall back to
/// showing the raw source.
pub async fn get_file(
State(state): State<Arc<Skald>>,
Query(q): Query<FileQuery>,
) -> Response {
let abs = match fs_tools::resolve(&q.path) {
Ok(p) => p,
Err(_) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {}", q.path)).into_response(),
};
if q.compile_latex && is_latex(&q.path) {
return match state.latex_compiler().compile(&abs).await {
Ok(pdf) => {
let mut response = pdf_response(pdf.bytes);
if q.force_download {
set_attachment(&mut response, &pdf_download_name(&q.path));
}
response
}
Err(err) => compile_error_response(err),
};
}
match tokio::fs::read(&abs).await {
Ok(bytes) => {
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type_for(&q.path)),
);
if q.force_download {
set_attachment(&mut response, &basename(&q.path));
}
response
}
Err(_) => (StatusCode::NOT_FOUND, format!("File not found: {}", q.path)).into_response(),
}
}
/// Mark a response as a browser download via `Content-Disposition: attachment`.
///
/// HTTP header values must be visible ASCII, so the filename is sanitised
/// (quotes, backslashes and non-ASCII bytes become `_`). This keeps it
/// dependency-free; the worst case for an exotic filename is a couple of `_`.
fn set_attachment(response: &mut Response, filename: &str) {
let safe: String = filename
.chars()
.map(|c| if c.is_ascii() && c != '"' && c != '\\' { c } else { '_' })
.collect();
if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{safe}\"")) {
response.headers_mut().insert(header::CONTENT_DISPOSITION, value);
}
}
/// Final path component, e.g. `docs/report.tex` → `report.tex`.
fn basename(path: &str) -> String {
Path::new(path)
.file_name()
.map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
}
/// Download name for a compiled LaTeX source: the stem with a `.pdf` extension,
/// e.g. `docs/report.tex` → `report.pdf`.
fn pdf_download_name(path: &str) -> String {
let stem = Path::new(path)
.file_stem()
.map_or_else(|| "output".to_string(), |s| s.to_string_lossy().to_string());
format!("{stem}.pdf")
}
/// Build a `200 OK` response carrying PDF bytes with the canonical
/// `application/pdf` content type and inline disposition.
fn pdf_response(bytes: Vec<u8>) -> Response {
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/pdf"),
);
response
}
/// Map a [`CompileError`] to an HTTP status that lets the frontend react:
/// `ToolMissing` → `501 Not Implemented`, `Timeout` → `504 Gateway Timeout`,
/// `Failed` → `422 Unprocessable Entity` (body = log), `Io` → `500`.
///
/// The body is always plain text so the viewer can show it directly.
fn compile_error_response(err: CompileError) -> Response {
let (status, body): (StatusCode, String) = match err {
CompileError::ToolMissing => (
StatusCode::NOT_IMPLEMENTED,
"latexmk is not installed on the server.".to_string(),
),
CompileError::Timeout => (
StatusCode::GATEWAY_TIMEOUT,
"LaTeX compilation aborted due to timeout.".to_string(),
),
CompileError::Failed { log } => (StatusCode::UNPROCESSABLE_ENTITY, log),
CompileError::Io(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("I/O error during compilation: {e}"),
),
};
let mut response = body.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
(status, response).into_response()
}
/// True for `.tex` / `.latex` extensions — i.e. inputs worth compiling.
fn is_latex(path: &str) -> bool {
matches!(
Path::new(path).extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref(),
Some("tex") | Some("latex")
)
}
/// Best-effort `Content-Type` from a file extension. Known binary types get their
/// specific MIME; everything else is served as UTF-8 text (markdown, code, configs,
/// and unknown files the viewer treats as plain text or "binary, no preview").
fn content_type_for(path: &str) -> &'static str {
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"tex" | "latex" => "application/x-tex",
"html" | "htm" => "text/html; charset=utf-8",
_ => "text/plain; charset=utf-8",
}
}
#[derive(Deserialize)]
pub struct SavePayload {
pub path: String,
pub content: String,
}
#[derive(Deserialize)]
pub struct CreatePayload {
pub path: String,
}
pub async fn create_file(
State(_state): State<Arc<Skald>>,
Json(body): Json<CreatePayload>,
) -> Result<StatusCode, ApiError> {
let abs = fs_tools::resolve(&body.path)?;
if abs.exists() {
return Err(anyhow::anyhow!("File già esistente: {}", body.path).into());
}
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&abs, "")?;
Ok(StatusCode::CREATED)
}
pub async fn save_file(
State(_state): State<Arc<Skald>>,
Json(body): Json<SavePayload>,
) -> Result<StatusCode, ApiError> {
let abs = fs_tools::resolve(&body.path)?;
if !abs.exists() {
return Err(anyhow::anyhow!("File not found: {}", body.path).into());
}
std::fs::write(&abs, &body.content)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct RenamePayload {
pub old_path: String,
pub new_path: String,
}
pub async fn rename_file(
State(_state): State<Arc<Skald>>,
Json(body): Json<RenamePayload>,
) -> Result<StatusCode, ApiError> {
let old_abs = fs_tools::resolve(&body.old_path)?;
let new_abs = fs_tools::resolve(&body.new_path)?;
if !old_abs.exists() {
return Err(anyhow::anyhow!("File non trovato: {}", body.old_path).into());
}
if new_abs.exists() {
return Err(anyhow::anyhow!("File già esistente: {}", body.new_path).into());
}
if let Some(parent) = new_abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&old_abs, &new_abs)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn delete_file(
State(_state): State<Arc<Skald>>,
Query(q): Query<FileQuery>,
) -> Result<StatusCode, ApiError> {
let abs = fs_tools::resolve(&q.path)?;
if !abs.exists() {
return Err(anyhow::anyhow!("File non trovato: {}", q.path).into());
}
std::fs::remove_file(&abs)?;
Ok(StatusCode::NO_CONTENT)
}
fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec<String>) -> anyhow::Result<()> {
if !dir.exists() { return Ok(()); }
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if matches!(name, ".git" | "target" | "node_modules") { continue; }
walk(root, &path, out)?;
} else if path.is_file() {
let rel = path.strip_prefix(root)?.to_string_lossy().to_string();
out.push(rel);
}
}
Ok(())
}
+77
View File
@@ -0,0 +1,77 @@
use axum::{Json, extract::State, http::StatusCode};
use serde::Deserialize;
use crate::core::image_generate::{ImageGenerateModelInfo, ImageGenerateModelRecord};
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
// ── GET /api/image-generate/models ───────────────────────────────────────────
pub async fn list_models(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<ImageGenerateModelInfo>>, ApiError> {
Ok(Json(skald.image_generator_manager().list_all_info().await))
}
// ── POST /api/image-generate/models ──────────────────────────────────────────
#[derive(Deserialize)]
pub struct ModelPayload {
pub provider_id: i64,
pub model_id: String,
pub name: String,
pub priority: Option<i32>,
}
impl From<ModelPayload> for ImageGenerateModelRecord {
fn from(p: ModelPayload) -> Self {
ImageGenerateModelRecord {
id: 0,
provider_id: p.provider_id,
model_id: p.model_id.clone(),
name: if p.name.is_empty() { p.model_id } else { p.name },
priority: p.priority.unwrap_or(100),
}
}
}
pub async fn create_model(
State(skald): State<Arc<Skald>>,
Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> {
skald.image_generator_manager().add_model(ImageGenerateModelRecord::from(payload)).await?;
Ok(StatusCode::CREATED)
}
// ── GET /api/image-generate/models/{id} ──────────────────────────────────────
pub async fn get_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<ImageGenerateModelRecord>, ApiError> {
skald.image_generator_manager().get_model(id).await
.map(Json)
.ok_or_else(|| ApiError::not_found(format!("image generate model {id} not found")))
}
// ── PUT /api/image-generate/models/{id} ──────────────────────────────────────
pub async fn update_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> {
skald.image_generator_manager().update_model(id, ImageGenerateModelRecord::from(payload)).await?;
Ok(StatusCode::NO_CONTENT)
}
// ── DELETE /api/image-generate/models/{id} ───────────────────────────────────
pub async fn delete_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> {
skald.image_generator_manager().delete_model(id).await?;
Ok(StatusCode::NO_CONTENT)
}
+37
View File
@@ -0,0 +1,37 @@
use axum::{
extract::{Path, State},
http::{HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use tokio::fs;
use std::sync::Arc;
use crate::core::skald::Skald;
/// GET /api/images/:task_id
///
/// Serves a generated image from `data/images/<task_id>.png`.
pub async fn get_image(
State(skald): State<Arc<Skald>>,
Path(task_id): Path<String>,
) -> Response {
// Reject any path traversal attempts.
if task_id.contains('/') || task_id.contains('\\') || task_id.contains("..") {
return StatusCode::BAD_REQUEST.into_response();
}
let task_id = task_id.trim_end_matches(".png");
let path = skald.image_generator_manager().images_dir().join(format!("{task_id}.png"));
match fs::read(&path).await {
Ok(bytes) => {
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("image/png"),
);
response
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
+161
View File
@@ -0,0 +1,161 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use std::time::Duration;
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
// ── GET /api/inbox ────────────────────────────────────────────────────────────
//
// Returns all pending approval requests and clarification requests in a single
// response, so the frontend can show a unified Agent Inbox page with one fetch.
pub async fn list(State(skald): State<Arc<Skald>>) -> Json<Value> {
let items = skald.inbox().list_pending().await;
Json(json!({
"total": items.total,
"approvals": items.approvals,
"clarifications": items.clarifications,
"elicitations": items.elicitations,
}))
}
// ── POST /api/inbox/approvals/:request_id/resolve ─────────────────────────────
#[derive(Deserialize)]
pub struct ApprovePath { pub request_id: i64 }
#[derive(Deserialize)]
pub struct ApproveBody {
#[serde(default = "default_action")]
pub action: String,
#[serde(default)]
pub note: String,
/// Seconds for the bypass duration. `0` means indefinite (session-scoped).
/// Absent means no bypass.
pub bypass_secs: Option<u64>,
/// `"category"` | `"mcp_server"` | `"all"`. Defaults to auto-detect from tool info.
pub bypass_scope: Option<String>,
}
fn default_action() -> String { "approve".to_string() }
pub async fn resolve_approval(
State(skald): State<Arc<Skald>>,
Path(p): Path<ApprovePath>,
Json(body): Json<ApproveBody>,
) -> Result<Json<Value>, ApiError> {
// Peek info before resolving so we have session_id and tool metadata for bypass.
let info = skald.approval().get_pending(p.request_id).await;
if body.action == "reject" {
// Pass the raw note; the waiting session builds the canonical message.
skald.inbox().reject(p.request_id, body.note.clone()).await;
} else {
skald.inbox().approve(p.request_id).await;
// Apply bypass if requested (only on approve).
if let (Some(info), Some(bypass_secs)) = (info, body.bypass_secs) {
let duration = if bypass_secs == 0 { None } else { Some(Duration::from_secs(bypass_secs)) };
let scope = body.bypass_scope.as_deref().unwrap_or_else(|| {
if info.tool_category.is_some() { "category" }
else if info.mcp_server.is_some() { "mcp_server" }
else { "all" }
});
match scope {
"category" => {
if let Some(cat) = info.tool_category {
skald.approval().bypass_session_for_category(info.session_id, cat, duration).await;
} else {
apply_all_bypass(&skald, info.session_id, duration).await;
}
}
"mcp_server" => {
if let Some(server) = info.mcp_server {
skald.approval().bypass_session_for_mcp(info.session_id, server, duration).await;
} else {
apply_all_bypass(&skald, info.session_id, duration).await;
}
}
_ => apply_all_bypass(&skald, info.session_id, duration).await,
}
}
}
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action })))
}
async fn apply_all_bypass(skald: &Skald, session_id: i64, duration: Option<Duration>) {
match duration {
Some(d) => skald.approval().bypass_session_for(session_id, d).await,
None => skald.approval().bypass_session(session_id).await,
}
}
// ── POST /api/inbox/clarifications/:request_id/resolve ────────────────────────
#[derive(Deserialize)]
pub struct ClarifyPath { pub request_id: i64 }
#[derive(Deserialize)]
pub struct ClarifyBody {
pub answer: String,
}
pub async fn resolve_clarification(
State(skald): State<Arc<Skald>>,
Path(p): Path<ClarifyPath>,
Json(body): Json<ClarifyBody>,
) -> Result<Json<Value>, ApiError> {
if body.answer.trim().is_empty() {
return Err(ApiError::bad_request("answer must not be empty"));
}
let resolved = skald.inbox().answer(p.request_id, body.answer).await;
if resolved {
Ok(Json(json!({ "ok": true, "request_id": p.request_id })))
} else {
Err(ApiError::not_found("clarification request not found"))
}
}
// ── POST /api/inbox/elicitations/:request_id/resolve ──────────────────────────
//
// Resolve a server-initiated MCP elicitation. `action` is "accept"/"decline"/
// "cancel"; on "accept", `content` carries the field values (e.g. a password).
// The value is forwarded to the MCP server and is never logged or persisted.
#[derive(Deserialize)]
pub struct ElicitPath { pub request_id: i64 }
#[derive(Deserialize)]
pub struct ElicitBody {
#[serde(default = "default_elicit_action")]
pub action: String,
#[serde(default)]
pub content: Option<Value>,
}
fn default_elicit_action() -> String { "decline".to_string() }
pub async fn resolve_elicitation(
State(skald): State<Arc<Skald>>,
Path(p): Path<ElicitPath>,
Json(body): Json<ElicitBody>,
) -> Result<Json<Value>, ApiError> {
let action = match body.action.as_str() {
"accept" | "decline" | "cancel" => body.action.clone(),
other => return Err(ApiError::bad_request(format!("invalid action: {other}"))),
};
let resolved = skald.inbox().resolve_elicitation(p.request_id, action.clone(), body.content).await;
if resolved {
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": action })))
} else {
Err(ApiError::not_found("elicitation request not found"))
}
}
+268
View File
@@ -0,0 +1,268 @@
use std::time::Duration;
use axum::{Json, extract::State, http::StatusCode};
use serde::{Deserialize, Serialize};
use crate::config::LlmStrength;
use crate::core::llm::providers::RemoteLlmModelInfo;
use crate::core::llm::{LlmModelInfo, LlmModelRecord, LlmProviderInfo, LlmProviderRecord};
use crate::core::provider::{ProviderUiMeta, ReasoningMode};
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
// ── GET /api/llm/providers/{id}/models ───────────────────────────────────────
pub async fn provider_models(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<Vec<RemoteLlmModelInfo>>, ApiError> {
let models = skald.manager().llm_manager().list_provider_models(id).await?;
Ok(Json(models))
}
// ── GET /api/llm/providers/{id}/reasoning-mode?model_id=… ─────────────────────
#[derive(Deserialize)]
pub struct ReasoningModeQuery {
pub model_id: String,
}
/// Reasoning control descriptor for a (provider, model_id), used by the manual
/// "add model" form to render the right control before saving. `null` = the
/// model does not support reasoning.
pub async fn provider_reasoning_mode(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
axum::extract::Query(q): axum::extract::Query<ReasoningModeQuery>,
) -> Json<Option<ReasoningMode>> {
let mode = skald.manager().llm_manager().reasoning_mode_for(id, &q.model_id).await;
Json(mode)
}
// ── GET /api/llm/models/selector (used by the copilot dropdown) ──────────────
#[derive(Serialize)]
pub struct SelectorResponse {
pub models: Vec<String>,
pub default: String,
}
pub async fn selector(
State(skald): State<Arc<Skald>>,
) -> Result<Json<SelectorResponse>, ApiError> {
let mgr = skald.manager().llm_manager();
let models = mgr.client_names().await;
let default = mgr.default_name().await;
Ok(Json(SelectorResponse { models, default }))
}
// ── Providers ─────────────────────────────────────────────────────────────────
pub async fn list_providers(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<LlmProviderInfo>>, ApiError> {
Ok(Json(skald.manager().llm_manager().list_providers_info().await))
}
#[derive(Deserialize)]
pub struct ProviderPayload {
pub name: String,
#[serde(rename = "type")]
pub provider: String,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub description: Option<String>,
}
impl From<ProviderPayload> for LlmProviderRecord {
fn from(p: ProviderPayload) -> Self {
LlmProviderRecord {
id: 0, // assigned by DB
name: p.name,
provider: p.provider,
api_key: p.api_key,
base_url: p.base_url,
description: p.description,
}
}
}
pub async fn create_provider(
State(skald): State<Arc<Skald>>,
Json(payload): Json<ProviderPayload>,
) -> Result<StatusCode, ApiError> {
validate_provider_type(&skald, &payload.provider)?;
let record = LlmProviderRecord::from(payload);
skald.manager().llm_manager().add_provider(record).await?;
Ok(StatusCode::CREATED)
}
pub async fn get_provider(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<LlmProviderRecord>, ApiError> {
skald.manager().llm_manager().get_provider(id).await
.map(Json)
.ok_or_else(|| ApiError::not_found(format!("provider {id} not found")))
}
pub async fn update_provider(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
Json(payload): Json<ProviderPayload>,
) -> Result<StatusCode, ApiError> {
validate_provider_type(&skald, &payload.provider)?;
let record = LlmProviderRecord::from(payload);
skald.manager().llm_manager().update_provider(id, record).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn delete_provider(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> {
skald.manager().llm_manager().delete_provider(id).await?;
Ok(StatusCode::NO_CONTENT)
}
// ── Models ────────────────────────────────────────────────────────────────────
pub async fn list_models(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<LlmModelInfo>>, ApiError> {
let mgr = skald.manager().llm_manager();
// Warm the catalog cache for every provider concurrently so that price data
// is available for the join inside list_models_info(). Errors are ignored —
// a provider that is down or lacks model listing just shows no price.
let provider_ids: Vec<i64> = mgr.list_providers_info().await
.into_iter().map(|p| p.id).collect();
const PER_PROVIDER_TIMEOUT: Duration = Duration::from_secs(5);
let mut tasks = tokio::task::JoinSet::new();
for id in provider_ids {
let mgr = mgr.clone();
tasks.spawn(async move {
let _ = tokio::time::timeout(
PER_PROVIDER_TIMEOUT,
mgr.list_provider_models(id),
).await;
});
}
while tasks.join_next().await.is_some() {}
Ok(Json(mgr.list_models_info().await))
}
#[derive(Deserialize)]
pub struct ModelPayload {
pub provider_id: i64,
pub model_id: String,
pub name: String,
pub strength: Option<String>,
pub scope: Option<Vec<String>>,
pub is_default: Option<bool>,
pub priority: Option<i32>,
pub extra_params: Option<serde_json::Value>,
pub context_length: Option<i64>,
pub max_output_tokens: Option<i64>,
pub knowledge_cutoff: Option<String>,
pub capabilities: Option<Vec<String>>,
/// Selected reasoning value (JSON string for a `ValueSet`, JSON number for a
/// `Range`, or absent/null for off). Interpreted per provider.
pub reasoning: Option<serde_json::Value>,
}
impl TryFrom<ModelPayload> for LlmModelRecord {
type Error = ApiError;
fn try_from(p: ModelPayload) -> Result<Self, ApiError> {
Ok(LlmModelRecord {
id: 0,
provider_id: p.provider_id,
model_id: p.model_id.clone(),
name: if p.name.is_empty() { p.model_id } else { p.name },
strength: p.strength.as_deref().map(parse_strength).transpose()?,
scope: p.scope.unwrap_or_default(),
is_default: p.is_default.unwrap_or(false),
priority: p.priority.unwrap_or(100),
extra_params: p.extra_params,
context_length: p.context_length,
max_output_tokens: p.max_output_tokens,
knowledge_cutoff: p.knowledge_cutoff,
capabilities: p.capabilities.unwrap_or_default(),
// `null` from the client (reasoning cleared) must become `None`.
reasoning: p.reasoning.filter(|v| !v.is_null()),
})
}
}
pub async fn create_model(
State(skald): State<Arc<Skald>>,
Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> {
let record = LlmModelRecord::try_from(payload)?;
skald.manager().llm_manager().add_model(record).await?;
Ok(StatusCode::CREATED)
}
pub async fn get_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<LlmModelRecord>, ApiError> {
skald.manager().llm_manager().get_model(id).await
.map(Json)
.ok_or_else(|| ApiError::not_found(format!("model {id} not found")))
}
pub async fn update_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> {
let record = LlmModelRecord::try_from(payload)?;
skald.manager().llm_manager().update_model(id, record).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn delete_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> {
skald.manager().llm_manager().delete_model(id).await?;
Ok(StatusCode::NO_CONTENT)
}
// ── GET /api/llm/providers/types ──────────────────────────────────────────────
pub async fn provider_types(
State(skald): State<Arc<Skald>>,
) -> Json<Vec<ProviderUiMeta>> {
let metas = skald.provider_registry().all()
.iter()
.map(|p| p.ui_meta())
.collect();
Json(metas)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn validate_provider_type(skald: &Arc<Skald>, type_id: &str) -> Result<(), ApiError> {
if skald.provider_registry().contains(type_id) {
Ok(())
} else {
Err(ApiError::bad_request(format!("unknown provider type '{type_id}'")))
}
}
fn parse_strength(s: &str) -> Result<LlmStrength, ApiError> {
match s {
"very_low" => Ok(LlmStrength::VeryLow),
"low" => Ok(LlmStrength::Low),
"average" => Ok(LlmStrength::Average),
"high" => Ok(LlmStrength::High),
"very_high" => Ok(LlmStrength::VeryHigh),
other => Err(ApiError::bad_request(format!("unknown strength '{other}'"))),
}
}
+11
View File
@@ -0,0 +1,11 @@
use axum::Json;
use axum::extract::State;
use serde_json::Value;
use std::sync::Arc;
use crate::core::skald::Skald;
/// Returns the list of running MCP servers and their available tools.
pub async fn list_servers(State(skald): State<Arc<Skald>>) -> Json<Vec<Value>> {
Json(skald.mcp().server_infos())
}
+40
View File
@@ -0,0 +1,40 @@
use axum::{
extract::{Path, State},
http::{HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use tokio::fs;
use std::sync::Arc;
use crate::core::mcp::content_type_for_ext;
use crate::core::skald::Skald;
/// GET /api/mcp-media/:file
///
/// Serves a persisted MCP tool-result media file from `data/mcp_media/<file>`.
/// `file` is a flat `<id>.<ext>` name produced by `McpManager::persist_media`;
/// the `Content-Type` is inferred from the extension.
pub async fn get_media(
State(skald): State<Arc<Skald>>,
Path(file): Path<String>,
) -> Response {
// Reject path traversal: only a flat `<id>.<ext>` filename is allowed.
if file.contains('/') || file.contains('\\') || file.contains("..") {
return StatusCode::BAD_REQUEST.into_response();
}
let ext = file.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
let path = skald.mcp().media_dir().join(&file);
match fs::read(&path).await {
Ok(bytes) => {
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type_for_ext(ext)),
);
response
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
+180
View File
@@ -0,0 +1,180 @@
pub mod agents;
pub mod commands;
pub mod config;
pub mod approval;
pub mod cron;
pub mod dev;
pub mod file_watch;
pub mod stats;
pub mod files;
pub mod image_generate_models;
pub mod images;
pub mod inbox;
pub mod llm;
pub mod mcp;
pub mod mcp_media;
pub mod plugins;
pub mod projects;
pub mod run_context;
pub mod sessions;
pub mod transcribe_audio;
pub mod transcribe_models;
pub mod tts_models;
pub mod uploads;
pub mod ws;
pub mod ws_session;
use std::sync::Arc;
use axum::{
Router,
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, patch, post, put},
};
use crate::core::skald::Skald;
pub fn router() -> Router<Arc<Skald>> {
Router::new()
.route("/agents", get(agents::list))
.route("/agents/{id}", get(agents::get))
.route("/agents/{id}/icon", get(agents::icon))
// Custom slash commands (file-based, read-only listing for autocomplete + /help)
.route("/commands", get(commands::list))
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
.route("/sessions/{id}", get(sessions::get_session_detail))
.route("/web/messages", get(sessions::web_messages))
.route("/{source}/messages", get(sessions::source_messages))
// File attachments: streamed to disk, so the default body-size limit is
// disabled on this route only.
.route("/{source}/uploads", post(uploads::upload).layer(DefaultBodyLimit::disable()))
// Source-agnostic approval resolve, keyed by globally-unique tool_call_id.
.route("/tools/{tool_call_id}/resolve", post(sessions::resolve_tool))
// Back-compat alias for older web clients that POST to /web/tools/...
.route("/web/tools/{tool_call_id}/resolve", post(sessions::resolve_tool))
.route("/ws", get(ws::handler))
.route("/ws/session/{id}", get(ws_session::handler))
.route("/file/watch", get(file_watch::handler))
// LLM selector (for copilot dropdown)
.route("/llm/models/selector", get(llm::selector))
// LLM providers
.route("/llm/providers/types", get(llm::provider_types))
.route("/llm/providers", get(llm::list_providers).post(llm::create_provider))
.route("/llm/providers/{id}", get(llm::get_provider).put(llm::update_provider).delete(llm::delete_provider))
.route("/llm/providers/{id}/models", get(llm::provider_models))
.route("/llm/providers/{id}/reasoning-mode", get(llm::provider_reasoning_mode))
// LLM models
.route("/llm/models", get(llm::list_models).post(llm::create_model))
.route("/llm/models/{id}", get(llm::get_model).put(llm::update_model).delete(llm::delete_model))
// Transcription — audio upload + model CRUD
.route("/transcribe/audio", post(transcribe_audio::transcribe_audio))
.route("/transcribe/has", get(transcribe_audio::has_transcribe))
.route("/transcribe/models", get(transcribe_models::list_models).post(transcribe_models::create_model))
.route("/transcribe/models/{id}", get(transcribe_models::get_model).put(transcribe_models::update_model).delete(transcribe_models::delete_model))
.route("/transcribe/providers/{id}/models", get(transcribe_models::provider_models))
// Image generation models
.route("/image-generate/models", get(image_generate_models::list_models).post(image_generate_models::create_model))
.route("/image-generate/models/{id}", get(image_generate_models::get_model).put(image_generate_models::update_model).delete(image_generate_models::delete_model))
// TTS models
.route("/tts/models", get(tts_models::list_models).post(tts_models::create_model))
.route("/tts/models/{id}", get(tts_models::get_model).put(tts_models::update_model).delete(tts_models::delete_model))
.route("/tts/providers/{id}/models", get(tts_models::provider_models))
// Projects
.route("/projects", get(projects::list).post(projects::create))
.route("/projects/{id}", get(projects::get_project).put(projects::update).delete(projects::delete))
.route("/projects/{id}/tickets", get(projects::list_tickets).post(projects::create_ticket))
.route("/projects/{id}/tickets/{tid}", delete(projects::delete_ticket))
.route("/projects/{id}/tickets/{tid}/start", post(projects::start_ticket))
.route("/projects/{id}/tickets/{tid}/reset", post(projects::reset_ticket))
.route("/projects/{id}/session", post(projects::open_session))
// Cron jobs
.route("/cron/jobs", get(cron::list))
.route("/cron/jobs/{id}", delete(cron::delete_job))
.route("/cron/jobs/{id}/kill", post(cron::kill_job))
.route("/cron/jobs/{id}/toggle", post(cron::toggle))
.route("/cron/jobs/{id}/run-context", patch(cron::set_run_context))
.route("/cron/runs", get(cron::list_runs))
// Agent Inbox — unified pending approvals + clarifications
.route("/inbox", get(inbox::list))
.route("/inbox/approvals/{request_id}/resolve", post(inbox::resolve_approval))
.route("/inbox/clarifications/{request_id}/resolve", post(inbox::resolve_clarification))
.route("/inbox/elicitations/{request_id}/resolve", post(inbox::resolve_elicitation))
// Approval — pending list + cross-session resolve (kept for backwards compat)
.route("/approval/pending", get(approval::list_pending))
.route("/approval/pending/{request_id}/resolve", post(approval::resolve_pending))
// Approval rules
.route("/approval/rules", get(approval::list_rules).post(approval::create_rule))
.route("/approval/rules/{id}", put(approval::update_rule).delete(approval::delete_rule))
.route("/approval/tools", get(approval::list_tools))
// Tool permission groups
.route("/tool-permission-groups", get(run_context::list_groups).post(run_context::create_group))
.route("/tool-permission-groups/{id}", put(run_context::update_group).delete(run_context::delete_group))
.route("/tool-permission-groups/{id}/duplicate", post(run_context::duplicate_group))
// Session tool_group assignment (runtime)
.route("/sessions/{session_id}/run-context", put(run_context::set_session_run_context))
// MCP
.route("/mcp/servers", get(mcp::list_servers))
// Dev / debug
.route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode))
.route("/dev/llm-requests", get(dev::list_llm_requests))
.route("/dev/llm-requests/{id}", get(dev::get_llm_request))
.route("/stats/llm", get(stats::llm_stats))
// Config properties
.route("/config", get(config::list_properties))
.route("/config/{key}", put(config::set_property))
// TIC
.route("/tic/trigger", post(tic_trigger))
// Plugins
.route("/plugins", get(plugins::list))
.route("/plugins/{id}", put(plugins::update))
// Images (generated by image_generate tool)
.route("/images/{task_id}", get(images::get_image))
// MCP tool-result media (images/audio/files returned by MCP servers)
.route("/mcp-media/{file}", get(mcp_media::get_media))
// Files
.route("/files", get(files::list_files))
.route("/file", get(files::get_file))
.route("/file", post(files::create_file))
.route("/file", put(files::save_file))
.route("/file", patch(files::rename_file))
.route("/file", delete(files::delete_file))
}
async fn tic_trigger(State(skald): State<Arc<Skald>>) -> impl IntoResponse {
tokio::spawn(async move {
Arc::clone(skald.tic_manager()).tick_now().await;
});
StatusCode::ACCEPTED
}
pub struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
pub fn bad_request(msg: impl Into<String>) -> Self {
Self { status: StatusCode::BAD_REQUEST, message: msg.into() }
}
pub fn not_found(msg: impl Into<String>) -> Self {
Self { status: StatusCode::NOT_FOUND, message: msg.into() }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, self.message).into_response()
}
}
impl<E: Into<anyhow::Error>> From<E> for ApiError {
fn from(e: E) -> Self {
let err = e.into();
tracing::error!(error = ?err, "internal API error");
Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: err.to_string() }
}
}
+31
View File
@@ -0,0 +1,31 @@
use axum::{
extract::{Path, State},
response::IntoResponse,
Json,
};
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<impl IntoResponse, ApiError> {
let plugins = skald.plugin_manager().list().await?;
Ok(Json(plugins))
}
#[derive(Deserialize)]
pub struct UpdateBody {
pub enabled: bool,
pub config: Value,
}
pub async fn update(
State(skald): State<Arc<Skald>>,
Path(id): Path<String>,
Json(body): Json<UpdateBody>,
) -> Result<impl IntoResponse, ApiError> {
skald.plugin_manager().update_config(&id, body.enabled, body.config).await?;
Ok(())
}
+294
View File
@@ -0,0 +1,294 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use crate::core::db::project_tickets::ProjectTicket;
use crate::core::db::projects::Project;
use crate::core::run_context::RunContext;
use crate::core::skald::Skald;
use super::ApiError;
/// Source-id prefix for a project's interactive chat session (e.g. `project-42`).
/// A hyphen (not `:`) is used so the id is URL-safe in `/api/{source}/messages`.
pub const PROJECT_SOURCE_PREFIX: &str = "project-";
/// Agent that drives interactive project-chat sessions.
const PROJECT_COORDINATOR_AGENT: &str = "project-coordinator";
// ── Request/Response types ────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct ProjectResponse {
pub id: i64,
pub name: String,
pub path: String,
pub description: String,
pub run_context: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl From<Project> for ProjectResponse {
fn from(p: Project) -> Self {
Self {
id: p.id, name: p.name, path: p.path,
description: p.description,
run_context: p.run_context, created_at: p.created_at, updated_at: p.updated_at,
}
}
}
#[derive(Deserialize)]
pub struct ProjectBody {
pub name: String,
pub path: String,
pub description: Option<String>,
pub security_group: Option<String>,
}
impl ProjectBody {
fn rc_json(&self) -> Option<String> {
self.security_group.as_ref().map(|sg| {
RunContext::with_security_group(Some(sg.clone())).to_db()
})
}
}
#[derive(Serialize)]
pub struct TicketResponse {
pub id: i64,
pub project_id: i64,
pub title: String,
pub description: String,
pub status: String,
pub agent_id: String,
pub run_context: Option<String>,
pub job_id: Option<i64>,
pub session_id: Option<i64>,
pub result: Option<String>,
pub error: Option<String>,
pub created_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
}
impl From<ProjectTicket> for TicketResponse {
fn from(t: ProjectTicket) -> Self {
Self {
id: t.id, project_id: t.project_id, title: t.title,
description: t.description, status: t.status, agent_id: t.agent_id,
run_context: t.run_context, job_id: t.job_id, session_id: t.session_id,
result: t.result, error: t.error, created_at: t.created_at,
started_at: t.started_at, completed_at: t.completed_at,
}
}
}
#[derive(Deserialize)]
pub struct TicketBody {
pub title: String,
pub description: Option<String>,
pub agent_id: Option<String>,
pub security_group: Option<String>,
}
impl TicketBody {
fn rc_json(&self) -> Option<String> {
self.security_group.as_ref().map(|sg| {
RunContext::with_security_group(Some(sg.clone())).to_db()
})
}
}
pub struct ProjectPath { pub id: i64 }
pub struct TicketPath { pub id: i64, pub tid: i64 }
impl<'de> Deserialize<'de> for ProjectPath {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Inner { id: i64 }
let inner = Inner::deserialize(d)?;
Ok(Self { id: inner.id })
}
}
impl<'de> Deserialize<'de> for TicketPath {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Inner { id: i64, tid: i64 }
let inner = Inner::deserialize(d)?;
Ok(Self { id: inner.id, tid: inner.tid })
}
}
// ── Project handlers ──────────────────────────────────────────────────────────
pub async fn list(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<ProjectResponse>>, ApiError> {
let projects = skald.projects().list().await?;
Ok(Json(projects.into_iter().map(Into::into).collect()))
}
pub async fn create(
State(skald): State<Arc<Skald>>,
Json(body): Json<ProjectBody>,
) -> Result<(StatusCode, Json<ProjectResponse>), ApiError> {
let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db);
let project = skald.projects().create(
&body.name,
&body.path,
body.description.as_deref().unwrap_or(""),
rc.as_ref(),
).await?;
Ok((StatusCode::CREATED, Json(project.into())))
}
pub async fn get_project(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>,
) -> Result<Json<ProjectResponse>, ApiError> {
let project = skald.projects().get(p.id).await?
.ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?;
Ok(Json(project.into()))
}
pub async fn update(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>,
Json(body): Json<ProjectBody>,
) -> Result<Json<ProjectResponse>, ApiError> {
let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db);
let found = skald.projects().update(
p.id,
&body.name,
&body.path,
body.description.as_deref().unwrap_or(""),
rc.as_ref(),
).await?;
if !found {
return Err(ApiError::not_found(format!("project {} not found", p.id)));
}
let project = skald.projects().get(p.id).await?
.ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?;
Ok(Json(project.into()))
}
pub async fn delete(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>,
) -> Result<StatusCode, ApiError> {
let found = skald.projects().delete(p.id).await?;
if found { Ok(StatusCode::NO_CONTENT) }
else { Err(ApiError::not_found(format!("project {} not found", p.id))) }
}
// ── Ticket handlers ───────────────────────────────────────────────────────────
pub async fn list_tickets(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<TicketResponse>>, ApiError> {
let tickets = skald.ticket_manager().list(p.id).await?;
Ok(Json(tickets.into_iter().map(Into::into).collect()))
}
pub async fn create_ticket(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>,
Json(body): Json<TicketBody>,
) -> Result<(StatusCode, Json<TicketResponse>), ApiError> {
let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db);
// Tickets run a task sub-agent — no default. The agent's `type == task` is enforced
// when the ticket starts, via TaskManager::spawn_async_job (require_task_agent).
let agent_id = body.agent_id.as_deref().map(str::trim).filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::bad_request("agent_id is required — pick a task agent for this ticket"))?;
let ticket = skald.ticket_manager().create(
p.id,
&body.title,
body.description.as_deref().unwrap_or(""),
agent_id,
rc.as_ref(),
).await?;
Ok((StatusCode::CREATED, Json(ticket.into())))
}
pub async fn delete_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>,
) -> Result<StatusCode, ApiError> {
let found = skald.ticket_manager().delete(tp.tid).await?;
if found { Ok(StatusCode::NO_CONTENT) }
else { Err(ApiError::not_found(format!("ticket {} not found", tp.tid))) }
}
pub async fn start_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>,
) -> Result<StatusCode, ApiError> {
skald.ticket_manager().start(tp.tid).await?;
Ok(StatusCode::ACCEPTED)
}
pub async fn reset_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>,
) -> Result<StatusCode, ApiError> {
skald.ticket_manager().reset(tp.tid).await?;
Ok(StatusCode::NO_CONTENT)
}
// ── Project chat session ──────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct SessionResponse {
pub source: String,
pub session_id: i64,
}
/// Resolves which agent + `RunContext` a `source` should be provisioned with.
///
/// `project-{id}` → (`project-coordinator`, project runtime context); any other source
/// → (`main`, no context). This is the single place that maps a source to its
/// provisioning config, shared by session-open and session-reset so the two never
/// diverge.
pub async fn provisioning_for_source(
skald: &Skald,
source: &str,
) -> Result<(String, Option<RunContext>), ApiError> {
let Some(id) = source
.strip_prefix(PROJECT_SOURCE_PREFIX)
.and_then(|s| s.parse::<i64>().ok())
else {
return Ok(("main".to_string(), None));
};
let project = skald.projects().get(id).await?
.ok_or_else(|| ApiError::not_found(format!("project {id} not found")))?;
let base = project.run_context.as_deref().and_then(RunContext::from_db);
let rc = crate::core::projects::build_runtime_run_context(&project, base);
Ok((PROJECT_COORDINATOR_AGENT.to_string(), Some(rc)))
}
/// POST /api/projects/{id}/session — open (or resume) the project's chat session.
/// Pre-creates the `project-{id}` source with the coordinator agent + project context
/// so the WebSocket finds the right session when the frontend connects.
pub async fn open_session(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>,
) -> Result<Json<SessionResponse>, ApiError> {
let source = format!("{PROJECT_SOURCE_PREFIX}{}", p.id);
let (agent, rc) = provisioning_for_source(&skald, &source).await?;
let session_id = skald.chat_hub()
.provision_session(&source, &agent, rc.as_ref(), false)
.await?;
Ok(Json(SessionResponse { source, session_id }))
}
+100
View File
@@ -0,0 +1,100 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::core::skald::Skald;
use super::ApiError;
// ── Tool Permission Groups ────────────────────────────────────────────────────
pub async fn list_groups(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Value>, ApiError> {
let groups = skald.run_context_manager().list_groups().await?;
Ok(Json(json!(groups)))
}
#[derive(Deserialize)]
pub struct GroupBody {
pub id: String,
pub name: String,
pub description: Option<String>,
}
pub async fn create_group(
State(skald): State<Arc<Skald>>,
Json(body): Json<GroupBody>,
) -> Result<Json<Value>, ApiError> {
skald.run_context_manager().create_group(&body.id, &body.name, body.description.as_deref()).await?;
Ok(Json(json!({ "id": body.id })))
}
#[derive(Deserialize)]
pub struct GroupPath { pub id: String }
#[derive(Deserialize)]
pub struct GroupUpdateBody {
pub name: String,
pub description: Option<String>,
}
pub async fn update_group(
State(skald): State<Arc<Skald>>,
Path(p): Path<GroupPath>,
Json(body): Json<GroupUpdateBody>,
) -> Result<Json<Value>, ApiError> {
let found = skald.run_context_manager().update_group(&p.id, &body.name, body.description.as_deref()).await?;
if !found {
return Err(ApiError::not_found("permission group not found"));
}
Ok(Json(json!({ "ok": true })))
}
pub async fn delete_group(
State(skald): State<Arc<Skald>>,
Path(p): Path<GroupPath>,
) -> Result<StatusCode, ApiError> {
skald.run_context_manager().delete_group(&p.id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct DuplicateGroupBody {
pub id: String,
pub name: String,
}
pub async fn duplicate_group(
State(skald): State<Arc<Skald>>,
Path(p): Path<GroupPath>,
Json(body): Json<DuplicateGroupBody>,
) -> Result<Json<Value>, ApiError> {
skald.run_context_manager().duplicate_group(&p.id, &body.id, &body.name).await?;
Ok(Json(json!({ "id": body.id })))
}
// ── Session run_context assignment ────────────────────────────────────────────
#[derive(Deserialize)]
pub struct SessionPath { pub session_id: i64 }
/// POST body: the full RunContext object, or JSON `null` to clear the context.
pub async fn set_session_run_context(
State(skald): State<Arc<Skald>>,
Path(p): Path<SessionPath>,
Json(ctx): Json<Option<crate::core::run_context::RunContext>>,
) -> Result<Json<Value>, ApiError> {
skald.run_context_manager().set_session_run_context(p.session_id, ctx.as_ref()).await?;
if let Some(handler) = skald.manager().active_handler(p.session_id).await {
handler.set_run_context(ctx).await;
}
Ok(Json(json!({ "ok": true })))
}
+600
View File
@@ -0,0 +1,600 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use axum::{
Json,
extract::{Path, Query, State},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources};
use crate::core::db::chat_sessions_stack::SessionStack;
use std::sync::Arc;
use crate::core::skald::Skald;
use crate::core::session::handler::ApprovalDecision;
use crate::core::approval::ApprovalManager;
use crate::core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn};
use super::ApiError;
// ── POST /api/sessions — start a new conversation ─────────────────────────────
#[derive(Deserialize)]
pub struct CreateQuery {
#[serde(default = "default_source")]
pub source: String,
}
fn default_source() -> String { "web".to_string() }
pub async fn create(
State(skald): State<Arc<Skald>>,
Query(q): Query<CreateQuery>,
) -> Result<Json<Value>, ApiError> {
// Resolve agent + RunContext from the source so project chats reset with the
// coordinator agent (not the default `main`), then provision a fresh session.
let (agent, rc) = super::projects::provisioning_for_source(&skald, &q.source).await?;
skald.chat_hub().provision_session(&q.source, &agent, rc.as_ref(), true).await?;
Ok(Json(json!({})))
}
// ── GET /api/web/messages ─────────────────────────────────────────────────────
pub async fn web_messages(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<Value>>, ApiError> {
messages_for_source(&skald, "web").await
}
// ── GET /api/:source/messages ─────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct SourcePath { pub source: String }
pub async fn source_messages(
State(skald): State<Arc<Skald>>,
Path(p): Path<SourcePath>,
) -> Result<Json<Vec<Value>>, ApiError> {
messages_for_source(&skald, &p.source).await
}
async fn messages_for_source(skald: &Arc<Skald>, source: &str) -> Result<Json<Vec<Value>>, ApiError> {
let session_id = match sources::active_session_id(skald.db(), source).await? {
Some(id) => id,
None => return Ok(Json(vec![])),
};
let main_stack = match chat_sessions_stack::main_for_session(skald.db(), session_id).await? {
Some(s) => s,
None => return Ok(Json(vec![])),
};
let subagent_map: HashMap<i64, SessionStack> =
chat_sessions_stack::all_for_session(skald.db(), session_id)
.await?
.into_iter()
.filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s)))
.collect();
let mut items: Vec<Value> = Vec::new();
build_items(skald.db(), skald.tools(), skald.approval(), &main_stack, &subagent_map, &mut items).await?;
Ok(Json(items))
}
// ── POST /api/tools/:tool_call_id/resolve — approve/reject a pending tool ─────
// (source-agnostic; /api/web/tools/... kept as a back-compat alias)
#[derive(Deserialize)]
pub struct ResolveToolPath {
pub tool_call_id: i64,
}
#[derive(Deserialize)]
pub struct ResolveToolBody {
/// `"approve"` or `"reject"`
pub action: String,
#[serde(default)]
pub note: String,
}
#[derive(Serialize)]
pub struct ResolveToolResponse {
pub tool_call_id: i64,
pub status: String,
pub result: Option<String>,
/// Result type tag (`"string"` | `"json"`) so the frontend keeps rich JSON
/// rendering after resolving an approval, matching the live `ToolDone` event.
pub result_type: String,
}
/// Approve or reject a `pending` tool call, resolved by its globally-unique
/// `tool_call_id`. Source-agnostic: the owning session is derived from the tool
/// call's own stack row, so approvals from any client (web/mobile/telegram/cron)
/// resolve against the correct session — there is no "current session" scoping.
pub async fn resolve_tool(
State(skald): State<Arc<Skald>>,
Path(p): Path<ResolveToolPath>,
Json(body): Json<ResolveToolBody>,
) -> Result<Json<ResolveToolResponse>, ApiError> {
// Look up the tool call by id alone — no active-session filter. Also pull the
// owning session_id so the post-restart path drives the correct session.
let tc = sqlx::query_as::<_, (i64, String, Option<String>, String, i64)>(
"SELECT t.id, t.name, t.arguments, t.status, ss.session_id
FROM chat_llm_tools t
JOIN chat_history h ON h.id = t.message_id
JOIN chat_sessions_stack ss ON ss.id = h.session_stack_id
WHERE t.id = ?",
)
.bind(p.tool_call_id)
.fetch_optional(&**skald.db())
.await?
.ok_or_else(|| anyhow::anyhow!(
"tool_call_id {} not found", p.tool_call_id
))?;
let (tc_id, tc_name, tc_args_raw, tc_status, session_id) = tc;
if tc_status != "pending" {
return Err(anyhow::anyhow!(
"tool_call {} is not pending (status: {})", tc_id, tc_status
).into());
}
let args: Value = tc_args_raw.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Object(Default::default()));
if body.action == "reject" {
// Pass the raw user note to the live session so the loop builds the
// canonical message; for the not-live path (no waiting session, e.g.
// after a restart) build the same message here and save it directly.
let live = skald.approval()
.resolve_for_tool_call(tc_id, ApprovalDecision::Rejected { note: body.note.clone() })
.await;
let msg = ApprovalDecision::rejection_message(&body.note);
if !live {
chat_llm_tools::reject(skald.db(), tc_id, &msg).await?;
}
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "rejected".to_string(),
result: Some(msg),
result_type: "string".to_string(),
}));
}
// `restart` calls process::exit — mark done in DB first.
if tc_name == tn::RESTART {
chat_llm_tools::complete(skald.db(), tc_id, "Riavvio avviato.", "string").await?;
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor).
unsafe { libc::_exit(-1) }
}
// ── Live path: LLM loop is blocked waiting for approval ──────────────────
if skald.approval()
.resolve_for_tool_call(tc_id, ApprovalDecision::Approved)
.await
{
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "running".to_string(),
result: None,
result_type: "string".to_string(),
}));
}
// ── Post-restart path: no in-memory oneshot to unblock. ───────────────────
// Sub-agent tools (`execute_task` etc.) cannot run through the flat
// `execute_tool` path — they need the recursive dispatcher. Mark the call
// pre-approved and drive the owning session's resume, which re-dispatches it
// via `execute_tool_call` (gate skipped) and continues the loop. Events stream
// to the reconnected client through the global bus; return immediately.
if tc_name == "execute_task" || tc_name == tn::EXECUTE_SUBTASK || tc_name == "run_subtask" {
let handler = skald.chat_hub().handler_for_session(session_id).await?;
handler.mark_pre_approved(tc_id);
let hub = skald.chat_hub().clone();
tokio::spawn(async move {
if let Err(e) = hub.resume_session(session_id).await {
tracing::warn!(session_id, tool_call_id = tc_id, error = %e, "post-restart resume of sub-agent tool failed");
}
});
return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "running".to_string(),
result: None,
result_type: "string".to_string(),
}));
}
// Simple tools: execute directly on the owning session and return the result.
let handler = skald.chat_hub().handler_for_session(session_id).await?;
match handler.execute_tool(&tc_name, args).await {
Ok(result) => {
let wire = result.to_wire();
let kind = result.kind();
chat_llm_tools::complete(skald.db(), tc_id, &wire, kind).await?;
Ok(Json(ResolveToolResponse {
tool_call_id: tc_id,
status: "done".to_string(),
result: Some(wire),
result_type: kind.to_string(),
}))
}
Err(e) => {
let msg = e.to_string();
chat_llm_tools::fail(skald.db(), tc_id, &msg).await?;
Err(anyhow::anyhow!(msg).into())
}
}
}
// ── GET /api/sessions — list sessions by source (paginated) ──────────────────
#[derive(Deserialize)]
pub struct ListSessionsQuery {
pub source: Option<String>,
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_per_page")]
pub per_page: i64,
}
fn default_page() -> i64 { 1 }
fn default_per_page() -> i64 { 20 }
pub async fn list_sessions(
State(skald): State<Arc<Skald>>,
Query(q): Query<ListSessionsQuery>,
) -> Result<Json<Value>, ApiError> {
let per_page = q.per_page.max(1).min(100);
let offset = ((q.page.max(1)) - 1) * per_page;
let src = q.source.as_deref();
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM chat_sessions cs
WHERE (? IS NULL OR cs.source = ?)",
)
.bind(src).bind(src)
.fetch_one(&**skald.db()).await?;
let rows = sqlx::query_as::<_, (i64, String, String, bool, bool, Option<String>, i64, Option<String>)>(
"SELECT cs.id, cs.source, cs.agent_id, cs.is_ephemeral, cs.is_interactive,
cs.created_at,
COUNT(h.id) AS message_count,
MAX(h.created_at) AS last_message_at
FROM chat_sessions cs
LEFT JOIN chat_sessions_stack ss ON ss.session_id = cs.id AND ss.depth = 0
LEFT JOIN chat_history h ON h.session_stack_id = ss.id AND h.status = 'ok'
WHERE (? IS NULL OR cs.source = ?)
GROUP BY cs.id
ORDER BY cs.id DESC
LIMIT ? OFFSET ?",
)
.bind(src).bind(src)
.bind(per_page).bind(offset)
.fetch_all(&**skald.db()).await?;
let items: Vec<Value> = rows.into_iter().map(|(id, source, agent_id, is_ephemeral, is_interactive, created_at, message_count, last_message_at)| {
json!({
"id": id,
"source": source,
"agent_id": agent_id,
"is_ephemeral": is_ephemeral,
"is_interactive": is_interactive,
"created_at": created_at,
"message_count": message_count,
"last_message_at": last_message_at,
})
}).collect();
Ok(Json(json!({
"items": items,
"total": total,
"page": q.page.max(1),
"per_page": per_page,
})))
}
// ── GET /api/sessions/:id — read-only session detail (debug view) ─────────────
#[derive(Deserialize)]
pub struct SessionIdPath { pub id: i64 }
pub async fn get_session_detail(
State(skald): State<Arc<Skald>>,
Path(p): Path<SessionIdPath>,
) -> Result<Json<Value>, ApiError> {
let session = chat_sessions::find_by_id(skald.db(), p.id)
.await?
.ok_or_else(|| ApiError::not_found(format!("session {} not found", p.id)))?;
let created_at: Option<String> = sqlx::query_scalar(
"SELECT created_at FROM chat_sessions WHERE id = ?",
)
.bind(p.id)
.fetch_optional(&**skald.db())
.await?;
let all_stacks = chat_sessions_stack::all_for_session(skald.db(), session.id).await?;
let subagent_map: HashMap<i64, SessionStack> = all_stacks
.iter()
.filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s.clone())))
.collect();
let main_stack = match all_stacks.into_iter().find(|s| s.depth == 0) {
Some(s) => s,
None => return Ok(Json(json!({
"session": {
"id": session.id, "source": session.source,
"agent_id": session.agent_id, "created_at": created_at,
},
"messages": [],
}))),
};
let mut messages: Vec<Value> = Vec::new();
build_debug_items(skald.db(), skald.tools(), &main_stack, &subagent_map, &mut messages).await?;
Ok(Json(json!({
"session": {
"id": session.id,
"source": session.source,
"agent_id": session.agent_id,
"is_interactive": session.is_interactive,
"is_ephemeral": session.is_ephemeral,
"created_at": created_at,
},
"messages": messages,
})))
}
/// Like `build_items` but includes synthetic user messages and reasoning content.
/// Used exclusively by the session-detail debug view.
fn build_debug_items<'a>(
db: &'a SqlitePool,
tools: &'a ToolRegistry,
stack: &'a SessionStack,
subagent_map: &'a HashMap<i64, SessionStack>,
items: &'a mut Vec<Value>,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let messages = chat_history::for_stack_all(db, stack.id).await?;
for msg in &messages {
let failed = msg.status == "failed";
match msg.role {
chat_history::Role::User => {
let attachments = msg.metadata.as_ref()
.map(|m| m.attachments.clone())
.unwrap_or_default();
// Custom slash commands render the typed command, not the
// expanded template persisted for LLM replay.
let content = msg.metadata.as_ref()
.and_then(|m| m.command.as_ref())
.map(|c| c.display.clone())
.unwrap_or_else(|| msg.content.clone());
items.push(json!({
"kind": "user",
"content": content,
"attachments": attachments,
"failed": failed,
"is_synthetic": msg.is_synthetic,
"created_at": msg.created_at,
}));
}
chat_history::Role::Agent => {}
chat_history::Role::Assistant => {
let tool_calls = chat_llm_tools::for_message(db, msg.id).await?;
if tool_calls.is_empty() {
items.push(json!({
"kind": "assistant",
"content": msg.content,
"reasoning": msg.reasoning_content,
"failed": failed,
"input_tokens": msg.input_tokens,
"output_tokens": msg.output_tokens,
"created_at": msg.created_at,
}));
} else {
if !msg.content.trim().is_empty() || msg.reasoning_content.is_some() {
items.push(json!({
"kind": "thinking",
"message_id": msg.id,
"content": msg.content,
"reasoning": msg.reasoning_content,
"failed": failed,
"input_tokens": msg.input_tokens,
"output_tokens": msg.output_tokens,
"created_at": msg.created_at,
}));
}
for tc in &tool_calls {
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
let (status, result, error) = match tc.status.as_str() {
"done" => ("done", tc.result.clone(), None),
"pending" => ("pending", None, None),
"running" => ("error", None, Some("Interrupted.".to_string())),
"cancelled" => ("cancelled", None, tc.result.clone()),
"rejected" => ("rejected", None, tc.result.clone()),
_ => ("error", None, tc.result.clone()),
};
let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short);
let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = tools.target_path(&tc.name, &args);
items.push(json!({
"kind": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"label_short": label_short,
"label_full": label_full,
"path": target_path,
"arguments": args,
"status": status,
"result": result,
"result_type": tc.result_type,
"error": error,
}));
if let Some(sub_stack) = subagent_map.get(&tc.id) {
items.push(json!({
"kind": "agent",
"stack_id": sub_stack.id,
"agent_id": sub_stack.agent_id,
"depth": sub_stack.depth,
"done": true,
}));
build_debug_items(db, tools, sub_stack, subagent_map, items).await?;
items.push(json!({
"kind": "agent_end",
"agent_id": sub_stack.agent_id,
"depth": sub_stack.depth,
}));
}
}
}
}
}
}
Ok(())
})
}
// ── Recursive message-tree builder ────────────────────────────────────────────
fn build_items<'a>(
db: &'a SqlitePool,
tools: &'a ToolRegistry,
approval: &'a ApprovalManager,
stack: &'a SessionStack,
subagent_map: &'a HashMap<i64, SessionStack>,
items: &'a mut Vec<Value>,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let messages = chat_history::for_stack_all(db, stack.id).await?;
for msg in &messages {
let failed = msg.status == "failed";
match msg.role {
chat_history::Role::User => {
// Skip synthetic messages (TIC notifications, etc.) — they are
// injected as user turns for the LLM but must not appear in the UI.
if msg.is_synthetic {
continue;
}
// `content` stays clean (typed text); attachments are surfaced
// structurally so the UI renders chips, not the LLM-facing block.
let attachments = msg.metadata.as_ref()
.map(|m| m.attachments.clone())
.unwrap_or_default();
// Custom slash commands render the typed command, not the
// expanded template persisted for LLM replay.
let content = msg.metadata.as_ref()
.and_then(|m| m.command.as_ref())
.map(|c| c.display.clone())
.unwrap_or_else(|| msg.content.clone());
items.push(json!({ "kind": "user", "content": content, "attachments": attachments, "failed": failed }));
}
chat_history::Role::Agent => {}
chat_history::Role::Assistant => {
let tool_calls = chat_llm_tools::for_message(db, msg.id).await?;
if tool_calls.is_empty() {
items.push(json!({
"kind": "assistant",
"content": msg.content,
"failed": failed,
"input_tokens": msg.input_tokens,
"output_tokens": msg.output_tokens,
}));
} else {
if !msg.content.trim().is_empty() {
items.push(json!({
"kind": "thinking",
"message_id": msg.id,
"content": msg.content,
"failed": failed,
"input_tokens": msg.input_tokens,
"output_tokens": msg.output_tokens,
}));
}
for tc in &tool_calls {
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
let (status, result, error) = match tc.status.as_str() {
"done" => ("done", tc.result.clone(), None),
// 'pending' means waiting for explicit user input (approval or
// clarification) — show the approval form with no error message.
"pending" => ("pending", None, None),
// 'running' means the tool was mid-execution when the session was
// interrupted — shown as "Interrupted" so the frontend can auto-resume.
"running" => ("error", None, Some("Interrupted.".to_string())),
// 'failed' means the tool completed with a genuine error — show
// the actual error message, NOT "Interrupted" (that would trigger
// a spurious auto-resume on page refresh).
_ => ("error", None, tc.result.clone()),
};
// A pending tool is awaiting approval. Surface the live
// `request_id` (only present while the server is up) so the
// client resolves via the source-agnostic WS/Inbox path with
// bypass support; `null` → the client falls back to resolving
// by the durable `tool_call_id`.
let request_id = if status == "pending" {
approval.request_id_for_tool_call(tc.id).await
} else {
None
};
let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short);
let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = tools.target_path(&tc.name, &args);
items.push(json!({
"kind": "tool",
"tool_call_id": tc.id,
"request_id": request_id,
"name": tc.name,
"label_short": label_short,
"label_full": label_full,
"path": target_path,
"arguments": args,
"status": status,
"result": result,
"result_type": tc.result_type,
"error": error,
}));
if let Some(sub_stack) = subagent_map.get(&tc.id) {
items.push(json!({
"kind": "agent",
"stack_id": sub_stack.id,
"agent_id": sub_stack.agent_id,
"depth": sub_stack.depth,
"done": true,
}));
build_items(db, tools, approval, sub_stack, subagent_map, items).await?;
items.push(json!({
"kind": "agent_end",
"agent_id": sub_stack.agent_id,
"depth": sub_stack.depth,
}));
}
}
}
}
}
}
Ok(())
})
}
+125
View File
@@ -0,0 +1,125 @@
use axum::{
extract::{Query, State},
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
#[derive(Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum StatsRange {
Hour,
Day,
#[default]
Week,
Month,
}
#[derive(Deserialize)]
pub struct StatsQuery {
pub range: Option<StatsRange>,
}
#[derive(Serialize)]
pub struct DailyStats {
pub day: String,
pub requests: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub cache_read_tokens: i64,
pub avg_duration_ms: f64,
}
#[derive(Serialize)]
pub struct ModelStats {
pub model_name: String,
pub requests: i64,
}
#[derive(Serialize)]
pub struct LlmStatsResponse {
pub daily: Vec<DailyStats>,
pub models: Vec<ModelStats>,
}
const SQL_DAILY_HOUR: &str =
"SELECT strftime('%H:%M', created_at, 'localtime') AS day,
COUNT(*) AS requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
AVG(duration_ms) AS avg_duration_ms
FROM llm_requests
WHERE created_at >= datetime('now', ?)
GROUP BY strftime('%H:%M', created_at, 'localtime')
ORDER BY day ASC";
const SQL_DAILY_HOUR_BUCKET: &str =
"SELECT strftime('%m-%d %H:00', created_at, 'localtime') AS day,
COUNT(*) AS requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
AVG(duration_ms) AS avg_duration_ms
FROM llm_requests
WHERE created_at >= datetime('now', ?)
GROUP BY strftime('%m-%d %H:00', created_at, 'localtime')
ORDER BY day ASC";
const SQL_DAILY_DATE: &str =
"SELECT DATE(created_at, 'localtime') AS day,
COUNT(*) AS requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
AVG(duration_ms) AS avg_duration_ms
FROM llm_requests
WHERE created_at >= datetime('now', ?)
GROUP BY DATE(created_at, 'localtime')
ORDER BY day ASC";
const SQL_MODELS: &str =
"SELECT model_name, COUNT(*) AS requests
FROM llm_requests
WHERE created_at >= datetime('now', ?)
GROUP BY model_name
ORDER BY requests DESC
LIMIT 6";
pub async fn llm_stats(
State(skald): State<Arc<Skald>>,
Query(params): Query<StatsQuery>,
) -> Result<impl IntoResponse, ApiError> {
let range = params.range.unwrap_or_default();
let (window, daily_sql) = match range {
StatsRange::Hour => ("-60 minutes", SQL_DAILY_HOUR),
StatsRange::Day => ("-24 hours", SQL_DAILY_HOUR_BUCKET),
StatsRange::Week => ("-7 days", SQL_DAILY_DATE),
StatsRange::Month => ("-30 days", SQL_DAILY_DATE),
};
let daily = sqlx::query_as::<_, (String, i64, i64, i64, i64, f64)>(daily_sql)
.bind(window)
.fetch_all(&**skald.db())
.await?
.into_iter()
.map(|(day, requests, input_tokens, output_tokens, cache_read_tokens, avg_duration_ms)| {
DailyStats { day, requests, input_tokens, output_tokens, cache_read_tokens, avg_duration_ms }
})
.collect::<Vec<_>>();
let models = sqlx::query_as::<_, (String, i64)>(SQL_MODELS)
.bind(window)
.fetch_all(&**skald.db())
.await?
.into_iter()
.map(|(model_name, requests)| ModelStats { model_name, requests })
.collect::<Vec<_>>();
Ok(Json(LlmStatsResponse { daily, models }))
}
+75
View File
@@ -0,0 +1,75 @@
use axum::{
Json,
extract::{Multipart, State},
http::StatusCode,
};
use serde::Serialize;
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
#[derive(Serialize)]
pub struct TranscribeResponse {
pub text: String,
}
/// POST /api/transcribe/audio
///
/// Accepts a multipart/form-data body with a single field `audio` containing
/// the raw audio bytes. The `Content-Type` of the part determines the format
/// passed to the transcriber (e.g. `audio/webm` → `"webm"`).
pub async fn transcribe_audio(
State(skald): State<Arc<Skald>>,
mut multipart: Multipart,
) -> Result<Json<TranscribeResponse>, ApiError> {
let transcriber = skald.transcribe_manager().get().await
.ok_or_else(|| ApiError::bad_request("no transcription model configured"))?;
let mut audio_bytes: Option<Vec<u8>> = None;
let mut format = "webm".to_string();
while let Some(field) = multipart.next_field().await
.map_err(|e| ApiError::bad_request(format!("multipart error: {e}")))?
{
if field.name() == Some("audio") {
// Derive format from content-type header of the part, e.g. "audio/webm" → "webm"
if let Some(ct) = field.content_type() {
if let Some(ext) = ct.split('/').nth(1) {
// Strip codec suffix: "webm;codecs=opus" → "webm"
format = ext.split(';').next().unwrap_or("webm").to_string();
}
}
audio_bytes = Some(
field.bytes().await
.map_err(|e| ApiError::bad_request(format!("failed to read audio: {e}")))?
.to_vec(),
);
}
}
let audio = audio_bytes
.ok_or_else(|| ApiError::bad_request("missing 'audio' field in multipart body"))?;
if audio.is_empty() {
return Err(ApiError::bad_request("audio field is empty"));
}
let text = transcriber.transcribe(audio, &format).await
.map_err(|e| {
tracing::warn!(error = %e, "transcription failed");
ApiError::from(e)
})?;
Ok(Json(TranscribeResponse { text }))
}
pub async fn has_transcribe(
State(skald): State<Arc<Skald>>,
) -> StatusCode {
if skald.transcribe_manager().get().await.is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
+89
View File
@@ -0,0 +1,89 @@
use axum::{Json, extract::State, http::StatusCode};
use serde::Deserialize;
use crate::core::transcribe::{RemoteTranscribeModelInfo, TranscribeModelInfo, TranscribeModelRecord};
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
// ── GET /api/transcribe/models ────────────────────────────────────────────────
pub async fn list_models(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<TranscribeModelInfo>>, ApiError> {
Ok(Json(skald.transcribe_manager().list_all_info().await))
}
// ── POST /api/transcribe/models ───────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ModelPayload {
pub provider_id: i64,
pub model_id: String,
pub name: String,
pub language: Option<String>,
pub priority: Option<i32>,
}
impl From<ModelPayload> for TranscribeModelRecord {
fn from(p: ModelPayload) -> Self {
TranscribeModelRecord {
id: 0, // assigned by DB
provider_id: p.provider_id,
model_id: p.model_id.clone(),
name: if p.name.is_empty() { p.model_id } else { p.name },
language: p.language,
priority: p.priority.unwrap_or(100),
}
}
}
pub async fn create_model(
State(skald): State<Arc<Skald>>,
Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> {
skald.transcribe_manager().add_model(TranscribeModelRecord::from(payload)).await?;
Ok(StatusCode::CREATED)
}
// ── GET /api/transcribe/models/{id} ──────────────────────────────────────────
pub async fn get_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<TranscribeModelRecord>, ApiError> {
skald.transcribe_manager().get_model(id).await
.map(Json)
.ok_or_else(|| ApiError::not_found(format!("transcribe model {id} not found")))
}
// ── PUT /api/transcribe/models/{id} ──────────────────────────────────────────
pub async fn update_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> {
skald.transcribe_manager().update_model(id, TranscribeModelRecord::from(payload)).await?;
Ok(StatusCode::NO_CONTENT)
}
// ── GET /api/transcribe/providers/{id}/models ─────────────────────────────────
pub async fn provider_models(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<Vec<RemoteTranscribeModelInfo>>, ApiError> {
let models = skald.transcribe_manager().list_provider_models(id).await?;
Ok(Json(models))
}
// ── DELETE /api/transcribe/models/{id} ───────────────────────────────────────
pub async fn delete_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> {
skald.transcribe_manager().delete_model(id).await?;
Ok(StatusCode::NO_CONTENT)
}
+95
View File
@@ -0,0 +1,95 @@
use axum::{Json, extract::State, http::StatusCode};
use serde::Deserialize;
use crate::core::tts::{RemoteTtsModelInfo, TtsModelInfo, TtsModelRecord};
use std::sync::Arc;
use crate::core::skald::Skald;
use super::ApiError;
// ── GET /api/tts/models ───────────────────────────────────────────────────────
pub async fn list_models(
State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<TtsModelInfo>>, ApiError> {
Ok(Json(skald.tts_manager().list_all_info().await))
}
// ── POST /api/tts/models ──────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ModelPayload {
pub provider_id: i64,
pub model_id: String,
pub voice_id: Option<String>,
pub name: String,
pub description: Option<String>,
pub instructions: Option<String>,
pub response_format: Option<String>,
pub priority: Option<i32>,
}
impl From<ModelPayload> for TtsModelRecord {
fn from(p: ModelPayload) -> Self {
TtsModelRecord {
id: 0,
provider_id: p.provider_id,
model_id: p.model_id.clone(),
voice_id: p.voice_id.filter(|v| !v.is_empty()),
name: if p.name.is_empty() { p.model_id } else { p.name },
description: p.description,
instructions: p.instructions,
response_format: p.response_format.filter(|v| !v.is_empty()),
priority: p.priority.unwrap_or(100),
}
}
}
pub async fn create_model(
State(skald): State<Arc<Skald>>,
Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> {
skald.tts_manager().add_model(TtsModelRecord::from(payload)).await?;
Ok(StatusCode::CREATED)
}
// ── GET /api/tts/models/{id} ──────────────────────────────────────────────────
pub async fn get_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<TtsModelRecord>, ApiError> {
skald.tts_manager().get_model(id).await
.map(Json)
.ok_or_else(|| ApiError::not_found(format!("tts model {id} not found")))
}
// ── PUT /api/tts/models/{id} ──────────────────────────────────────────────────
pub async fn update_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> {
skald.tts_manager().update_model(id, TtsModelRecord::from(payload)).await?;
Ok(StatusCode::NO_CONTENT)
}
// ── GET /api/tts/providers/{id}/models ───────────────────────────────────────
pub async fn provider_models(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<Vec<RemoteTtsModelInfo>>, ApiError> {
let models = skald.tts_manager().list_provider_models(id).await?;
Ok(Json(models))
}
// ── DELETE /api/tts/models/{id} ───────────────────────────────────────────────
pub async fn delete_model(
State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> {
skald.tts_manager().delete_model(id).await?;
Ok(StatusCode::NO_CONTENT)
}
+110
View File
@@ -0,0 +1,110 @@
use std::path::{Path as StdPath, PathBuf};
use std::sync::Arc;
use axum::{
Json,
extract::{Multipart, Path, State},
};
use tokio::io::AsyncWriteExt;
use core_api::message_meta::Attachment;
use crate::core::skald::Skald;
use crate::core::tools::fs as fs_tools;
use super::ApiError;
use super::sessions::SourcePath;
/// `POST /api/{source}/uploads`
///
/// Accepts a `multipart/form-data` body with one or more file fields and saves
/// each under `data/uploads/{session_id}/`. Bytes are streamed straight to disk
/// (`field.chunk()` → file), never buffered whole in RAM, so arbitrarily large
/// files are fine — the route disables the default body-size limit (see router).
///
/// Returns the saved [`Attachment`]s (project-root-relative path, name, MIME,
/// size) so the client can show chips and echo them back when sending the message.
pub async fn upload(
State(skald): State<Arc<Skald>>,
Path(p): Path<SourcePath>,
mut multipart: Multipart,
) -> Result<Json<Vec<Attachment>>, ApiError> {
// Resolve (creating if needed) the source's session so uploads land in the
// directory the message will reference.
let session_id = skald.chat_hub().session_handler(&p.source).await?.session_id;
let dir_rel = format!("data/uploads/{session_id}");
let dir_abs = fs_tools::resolve(&dir_rel)?;
tokio::fs::create_dir_all(&dir_abs).await?;
let mut saved: Vec<Attachment> = Vec::new();
while let Some(mut field) = multipart.next_field().await
.map_err(|e| ApiError::bad_request(format!("multipart error: {e}")))?
{
// Only fields carrying a filename are file uploads; skip plain text fields.
let Some(orig_name) = field.file_name().map(str::to_string) else { continue };
let mimetype = field.content_type().map(str::to_string);
let base_name = sanitize_filename(&orig_name);
let (abs_path, final_name) = unique_target(&dir_abs, &base_name);
let mut file = tokio::fs::File::create(&abs_path).await
.map_err(|e| ApiError::from(anyhow::anyhow!("cannot create {}: {e}", abs_path.display())))?;
let mut size: u64 = 0;
while let Some(chunk) = field.chunk().await
.map_err(|e| ApiError::bad_request(format!("upload read error: {e}")))?
{
file.write_all(&chunk).await?;
size += chunk.len() as u64;
}
file.flush().await?;
saved.push(Attachment {
path: format!("{dir_rel}/{final_name}"),
name: final_name,
mimetype,
filesize: Some(size),
});
}
Ok(Json(saved))
}
/// Reduces an arbitrary client filename to a safe basename: directory components
/// are dropped and an empty/`.`/`..` result falls back to `"file"`.
fn sanitize_filename(raw: &str) -> String {
let base = StdPath::new(raw)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.trim();
if base.is_empty() || base == "." || base == ".." {
"file".to_string()
} else {
base.to_string()
}
}
/// Returns a non-colliding `(absolute_path, final_name)` inside `dir`. If `name`
/// already exists, inserts `_1`, `_2`, … before the extension.
fn unique_target(dir: &StdPath, name: &str) -> (PathBuf, String) {
let candidate = dir.join(name);
if !candidate.exists() {
return (candidate, name.to_string());
}
let path = StdPath::new(name);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
let ext = path.extension().and_then(|s| s.to_str());
for n in 1.. {
let next = match ext {
Some(ext) => format!("{stem}_{n}.{ext}"),
None => format!("{stem}_{n}"),
};
let candidate = dir.join(&next);
if !candidate.exists() {
return (candidate, next);
}
}
unreachable!("unique_target loop always returns")
}
+531
View File
@@ -0,0 +1,531 @@
use std::sync::Arc;
use axum::{
extract::{
Query, State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
};
use serde::Deserialize;
use serde_json::Value;
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
use crate::core::chat_hub::{ModelCommandOutcome, SendMessageOptions};
use crate::core::events::{ClientMessage, ServerEvent};
use crate::core::skald::Skald;
use core_api::command::CommandApi;
#[derive(Deserialize)]
pub struct WsParams {
source: Option<String>,
}
const WEB_FORMAT_CONTEXT: &str = "\
You are responding in a web chat interface. Use standard Markdown formatting for all responses.\n\
\n\
IMAGES: If image generation is active, you can display images to the user using standard Markdown \
image syntax with the URL. Always set a max-width style to avoid the image taking up the full screen width, \
e.g. <img src=\"URL\" style=\"max-width:480px\">. \
The URL returned by image_generate already points to the correct endpoint — use it as-is. \
Do NOT append \".png\" or any extension to the URL.\n\
\n\
FILES: To let the user look at a file directly, call show_file_to_user(path). Supported: \
Markdown, source code, images (PNG/JPG/GIF/WebP/SVG), PDF, and LaTeX (.tex — auto-compiled \
to PDF server-side). HTML opens in a new browser tab. Prefer this over pasting long file \
contents into chat.";
const HELP_TEXT: &str = "\
**Available commands**\n\n\
**/clear** — start a new conversation\n\
**/new** — alias for /clear\n\
**/models** — list available LLM models, ordered by priority\n\
**/model <N|name|auto>** — select the model for this chat\n\
**/context** — show last turn's token usage\n\
**/cost** — show total spend for this session (USD)\n\
**/compact** — force context compaction\n\
**/resettools** — remove all activated tool groups (MCP + config) from the session\n\
**/sethome** — set web as the destination for agent notifications\n\
**/help** — this message";
/// Builds the `/help` text: the static system-command list plus a dynamically
/// discovered "Custom commands" section (`commands/<name>/`).
fn dynamic_help(skald: &Skald) -> String {
let mut out = String::from(HELP_TEXT);
let cmds = skald.command_manager().list_enabled();
if !cmds.is_empty() {
out.push_str("\n\n**Custom commands**");
for c in cmds {
out.push_str(&format!("\n**/{}** — {}", c.name, c.description));
}
}
out
}
// ── Upgrade ───────────────────────────────────────────────────────────────────
pub async fn handler(
ws: WebSocketUpgrade,
Query(params): Query<WsParams>,
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))
}
// ── 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 {
Ok(h) => h,
Err(e) => {
let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await;
return;
}
};
info!(source, "WebSocket connected");
let mut rx = skald.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
// `rx`, so a turn that finishes right after still delivers its Done via `rx`.
let _ = socket.send(to_msg(&ServerEvent::TurnRunning {
running: session_handler.is_processing(),
})).await;
loop {
tokio::select! {
// ── Inbound: message from the browser ────────────────────────────
msg = socket.recv() => {
let text = match msg {
Some(Ok(Message::Text(t))) => t,
Some(Ok(Message::Close(_))) | None => return,
_ => continue,
};
// ── resume ────────────────────────────────────────────────────
if is_resume_msg(&text) {
info!("web WS: resume requested");
let hub = Arc::clone(skald.chat_hub());
let src = source.clone();
tokio::spawn(async move {
if let Err(e) = hub.resume(&src).await {
tracing::error!(error = %e, source = %src, "resume failed");
}
});
continue;
}
// ── cancel / approval / question (mid-turn controls) ──────────
if is_cancel_msg(&text) {
info!("web WS: cancel requested");
session_handler.cancel();
session_handler.cancel_pending_approvals().await;
session_handler.cancel_pending_questions().await;
continue;
}
if handle_approval_msg(&text, skald.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; }
// ── /sethome ──────────────────────────────────────────────────
let client_msg: ClientMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
let _ = socket.send(to_msg(&ServerEvent::Error {
message: format!("invalid message: {e}"),
})).await;
continue;
}
};
let cmd = client_msg.content.trim();
if cmd == "/sethome" {
let msg = match skald.chat_hub().set_home(&source).await {
Ok(_) => "🏠 Web impostato come **home**. Le notifiche degli agenti arriveranno qui.".to_string(),
Err(e) => format!("⚠️ Errore: {e}"),
};
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: msg,
input_tokens: None,
output_tokens: None,
})).await;
continue;
}
if cmd == "/help" {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: dynamic_help(&skald),
input_tokens: None,
output_tokens: None,
})).await;
continue;
}
if cmd == "/context" {
match skald.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());
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: format!("↑{input_str} tok · ↓{output_str} tok"),
input_tokens: None,
output_tokens: None,
})).await;
}
Err(e) => {
let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await;
}
}
continue;
}
if cmd == "/cost" {
match skald.chat_hub().cost_info(&source).await {
Ok(Some(c)) => {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: format!("💰 Costo sessione: ${c:.4}"),
input_tokens: None,
output_tokens: None,
})).await;
}
Ok(None) => {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: "💰 Nessun costo registrato per questa sessione.".to_string(),
input_tokens: None,
output_tokens: None,
})).await;
}
Err(e) => {
let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await;
}
}
continue;
}
if cmd == "/compact" {
match skald.chat_hub().force_compact(&source).await {
Ok(true) => {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: "✅ Contesto compattato.".to_string(),
input_tokens: None,
output_tokens: None,
})).await;
}
Ok(false) => {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: "⏩ Compaction skipped (no messages to summarize or compaction disabled).".to_string(),
input_tokens: None,
output_tokens: None,
})).await;
}
Err(e) => {
let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await;
}
}
continue;
}
if cmd == "/resettools" {
match skald.chat_hub().reset_mcp(&source).await {
Ok(()) => {
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: "✅ Activated tool groups removed from the session.".to_string(),
input_tokens: None,
output_tokens: None,
})).await;
}
Err(e) => {
let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await;
}
}
continue;
}
if cmd == "/models" {
let items = skald.chat_hub().list_clients_marked(&source).await;
let content = format_models_md(&items);
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content,
input_tokens: None,
output_tokens: None,
})).await;
continue;
}
if let Some(arg) = cmd.strip_prefix("/model").map(str::trim) {
let outcome = skald.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(),
ModelCommandOutcome::Error(msg) => format!("⚠️ {msg}"),
};
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content,
input_tokens: None,
output_tokens: None,
})).await;
continue;
}
// ── Custom slash command? ─────────────────────────────────────
// A recognised custom `/command` expands its `COMMAND.md` template
// into a normal user message on the `main` session (fully
// interactive: the model can then ask questions, iterate, dispatch
// sub-agents). Any other `/...` is an unknown command and is never
// forwarded to the LLM — reply with a not-found notice + help.
let mut command_ref: Option<core_api::message_meta::CommandRef> = None;
let content: String = if cmd.starts_with('/') {
let rest = &cmd[1..];
let name = rest.split_whitespace().next().unwrap_or("");
let args = rest.strip_prefix(name).map(str::trim).unwrap_or("");
match skald.command_manager().resolve(name) {
Some(command) => {
command_ref = Some(core_api::message_meta::CommandRef {
name: command.name.clone(),
display: cmd.to_string(),
});
skald.command_manager().expand(&command.template, args)
}
None => {
let first = cmd.split_whitespace().next().unwrap_or(cmd);
let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0,
stack_id: 0,
content: format!("Unknown command: {first}\n\n{}", dynamic_help(&skald)),
input_tokens: None,
output_tokens: None,
})).await;
continue;
}
}
} else {
client_msg.content.clone()
};
// ── Regular LLM message ───────────────────────────────────────
// Attachments uploaded beforehand, plus an optional custom-command
// marker. Persisted on the user turn as MessageMetadata; the
// [SYSTEM INFO] block the LLM sees is generated on the fly by the
// MessageBuilder (never stored as text), and the UI renders the
// command's `display` instead of the expanded `content`.
let attachments = client_msg.attachments.clone();
let metadata = (!attachments.is_empty() || command_ref.is_some())
.then(|| core_api::message_meta::MessageMetadata {
attachments: attachments.clone(),
command: command_ref.clone(),
});
// No echo here: the `UserMessage` event is emitted when the message is
// actually persisted to history (at turn start, or at a round boundary
// for messages injected mid-turn). This telnet-style echo is what makes
// the bubble appear in its correct position; clients never render the
// message optimistically on send.
let opts = SendMessageOptions {
metadata,
// The web dropdown is now a view of backend state; the pinned
// 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,
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![
crate::core::tools::show_file::make_tool(
Arc::clone(skald.chat_hub()),
source.clone(),
),
],
..Default::default()
};
// 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 {
tracing::error!(error = %e, source = %source, "send_message enqueue failed");
}
}
// ── Outbound: event from ChatHub → forward to browser ─────────────
event = rx.recv() => {
match event {
Ok(ge) => {
// Forward events for this connection's source.
// ApprovalResolved is forwarded regardless of source so the
// copilot can react to approvals resolved from other clients.
let forward = ge.source.as_deref() == Some(source.as_str())
|| matches!(ge.event, ServerEvent::ApprovalResolved { .. });
if !forward { continue; }
debug!(event_type = ge.event.type_name(), "sending event to client");
if socket.send(to_msg(&ge.event)).await.is_err() {
return;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "web WS: event stream lagged");
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn is_cancel_msg(text: &str) -> bool {
serde_json::from_str::<Value>(text)
.ok()
.and_then(|v| v["type"].as_str().map(|s| s == "cancel"))
.unwrap_or(false)
}
fn is_resume_msg(text: &str) -> bool {
serde_json::from_str::<Value>(text)
.ok()
.and_then(|v| v["type"].as_str().map(|s| s == "resume"))
.unwrap_or(false)
}
/// Returns true if the message was an approval/rejection (caller should `continue`).
async fn handle_approval_msg(
text: &str,
chat_hub: &Arc<crate::core::chat_hub::ChatHub>,
) -> bool {
let Ok(v) = serde_json::from_str::<Value>(text) else { return false };
let Some(request_id) = v["request_id"].as_i64() else { return false };
match v["type"].as_str() {
Some("approve_write") | Some("approve_tool") => {
// Optional bypass: `bypass_secs` present → approve + bypass.
// Value 0 means indefinite (session); any positive value is seconds.
if let Some(bypass_secs) = v["bypass_secs"].as_u64() {
let secs = if bypass_secs == 0 { None } else { Some(bypass_secs) };
chat_hub.approval.approve_with_bypass(request_id, secs).await;
} else {
chat_hub.approve(request_id).await;
}
}
Some("reject_write") | Some("reject_tool") => {
let note = v["note"].as_str().unwrap_or("").to_string();
chat_hub.reject(request_id, note).await;
}
_ => return false,
};
true
}
/// Returns true if the message was a question answer (caller should `continue`).
async fn handle_question_answer_msg(
text: &str,
handler: &Arc<crate::core::session::handler::ChatSessionHandler>,
) -> bool {
let Ok(v) = serde_json::from_str::<Value>(text) else { return false };
if v["type"].as_str() != Some("answer_question") { return false }
let Some(request_id) = v["request_id"].as_i64() else { return false };
let answer = v["answer"].as_str().unwrap_or("").to_string();
handler.resolve_question(request_id, answer).await;
true
}
/// Returns true if the message was a select_client event from the web dropdown
/// (caller should `continue`). Mutates the backend's per-source pinned client
/// via `set_selected_client`, which broadcasts `ClientSelected` to every client
/// of the source (so all open tabs/mobile update).
async fn handle_select_client_msg(
text: &str,
source: &str,
chat_hub: &Arc<crate::core::chat_hub::ChatHub>,
) -> bool {
let Ok(v) = serde_json::from_str::<Value>(text) else { return false };
if v["type"].as_str() != Some("select_client") { return false }
let Some(client) = v["client"].as_str() else { return false };
let client = client.to_string();
if client == "auto" {
chat_hub.clear_selected_client(source).await;
} else {
chat_hub.set_selected_client(source, client).await;
}
true
}
/// Returns true if the message was an inbound data push (caller should `continue`).
/// Dispatches `{"type":"data","stream":"...","payload":{...}}` to the appropriate manager.
fn handle_data_msg(text: &str, skald: &Arc<Skald>) -> bool {
let Ok(v) = serde_json::from_str::<Value>(text) else { return false };
if v["type"].as_str() != Some("data") { return false }
let Ok(msg) = serde_json::from_value::<crate::core::events::InboundDataMessage>(v) else {
return true;
};
match msg.stream.as_str() {
"location" => {
let lat = msg.payload["lat"].as_f64();
let lng = msg.payload["lng"].as_f64();
let acc = msg.payload["accuracy"].as_f64();
let live = msg.payload["is_live"].as_bool().unwrap_or(true);
if let (Some(lat), Some(lng)) = (lat, lng) {
skald.location_manager().update(
"remote",
crate::core::location::GpsCoord { latitude: lat, longitude: lng },
acc,
live,
);
tracing::debug!(lat, lng, "location updated from remote client");
} else {
tracing::warn!(stream = "location", "missing lat/lng in payload");
}
}
other => tracing::warn!(stream = other, "unknown data stream, ignoring"),
}
true
}
fn to_msg(event: &ServerEvent) -> Message {
Message::Text(event.to_json().into())
}
// ── /models formatter (Markdown, web-specific) ───────────────────────────────
//
// Business logic for `/model` lives in `ChatHub::apply_model_command`; the
// `/models` listing uses `ChatHub::list_clients_marked` and only needs
// rendering. A future `/reasonings` can mirror this thin formatter.
fn format_models_md(items: &[(usize, String, bool)]) -> String {
let mut text = String::from("**Available models**\n\n");
for (i, name, is_current) in items {
let marker = if *is_current { "" } else { "" };
text.push_str(&format!("{marker} `{i:2}` {name}\n"));
}
text.push_str("\nUse `/model N`, `/model name`, or `/model auto`.");
text
}
+60
View File
@@ -0,0 +1,60 @@
use std::sync::Arc;
use axum::{
extract::{
Path, State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
};
use tokio::sync::broadcast;
use tracing::{info, warn};
use crate::core::skald::Skald;
pub async fn handler(
ws: WebSocketUpgrade,
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, skald, id))
}
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, session_id: i64) {
info!(session_id, "session-watch WS connected");
let mut rx = skald.chat_hub().events("session-watch");
loop {
tokio::select! {
// Detect client disconnect.
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
_ => {} // ignore any inbound data
}
}
// Forward bus events filtered by session_id.
event = rx.recv() => {
match event {
Ok(ge) => {
if ge.session_id != Some(session_id) {
continue;
}
let text = ge.event.to_json();
if socket.send(Message::Text(text.into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(session_id, skipped = n, "session-watch WS: event bus lagged");
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
}
info!(session_id, "session-watch WS disconnected");
}