First Version
This commit is contained in:
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(¶ms.agent_id).bind(¶ms.agent_id)
|
||||
.bind(¶ms.source).bind(¶ms.source)
|
||||
.bind(¶ms.from).bind(¶ms.from)
|
||||
.bind(¶ms.to).bind(¶ms.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(¶ms.agent_id).bind(¶ms.agent_id)
|
||||
.bind(¶ms.source).bind(¶ms.source)
|
||||
.bind(¶ms.from).bind(¶ms.from)
|
||||
.bind(¶ms.to).bind(¶ms.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,
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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}'"))),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user