- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
13 KiB
Rust
296 lines
13 KiB
Rust
//! API for the local command line client (`codecraft`): the CLI runs the
|
|
//! agent loop and the file/shell tools on the user's machine; the server
|
|
//! provides the configuration (modes, models, rules, skills, theme), acts as
|
|
//! the model gateway (keys, budgets, statistics) and runs its MCP tools.
|
|
|
|
use crate::api::{ApiError, ApiResult, AppState};
|
|
use crate::provider::{ChatRequest, StreamEvent, ToolDef};
|
|
use crate::store::{UsageRecord, User};
|
|
use crate::tools::ToolCtx;
|
|
use crate::types::{Message, now_ms};
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
|
use axum::{Extension, Json};
|
|
use futures::stream::Stream;
|
|
use serde::Deserialize;
|
|
use serde_json::{Value, json};
|
|
use std::convert::Infallible;
|
|
use std::path::PathBuf;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
/// Everything the CLI needs to run like the web UI.
|
|
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
|
let cfg = app.config();
|
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
|
let agents: Vec<Value> = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
|
|
.into_iter()
|
|
.map(|a| {
|
|
let mut v = json!(a);
|
|
v["prompt"] = json!(a.prompt);
|
|
v
|
|
})
|
|
.collect();
|
|
let commands: Vec<Value> = crate::prompts::commands(&app, &root, &root)
|
|
.into_iter()
|
|
.filter(|c| c.description.len() < 400)
|
|
.map(|c| json!({"name": c.name, "description": c.description, "body": c.body, "agent": c.agent}))
|
|
.collect();
|
|
let skills: Vec<Value> = crate::prompts::skills(&app, &root, &root).into_iter().map(|s| json!({"name": s.name, "description": s.description})).collect();
|
|
let (direct, deferred) = app.mcp.tools(&cfg, Some(&user));
|
|
let mut mcp_tools: Vec<ToolDef> = direct.iter().map(|t| t.def()).collect();
|
|
if !deferred.is_empty() {
|
|
mcp_tools.push(crate::tools::Tool::def(&crate::mcp::McpSearch));
|
|
mcp_tools.push(crate::tools::Tool::def(&crate::mcp::McpCall));
|
|
}
|
|
let prefs: Value = app.store.user_setting(&user.id, "prefs")?;
|
|
let memory: String = app.store.user_setting(&user.id, "memory")?;
|
|
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
|
Ok(Json(json!({
|
|
"version": codecraft_core::version::VERSION,
|
|
"user": user,
|
|
"models": app.registry().models_json(),
|
|
"default_model": default_model,
|
|
"small_model": cfg.server.small_model,
|
|
"currency": cfg.server.currency,
|
|
"base_prompt": crate::prompts::BASE_PROMPT,
|
|
"auto_mode_prompt": crate::prompts::AUTO_MODE_PROMPT,
|
|
"agents": agents,
|
|
"commands": commands,
|
|
"skills": skills,
|
|
"permissions": cfg.permissions,
|
|
"mcp_tools": mcp_tools,
|
|
"deferred_mcp": app.mcp.deferred_summary(&cfg, Some(&user)).iter().map(|(s, n, names)| json!({"server": s, "tools": n, "examples": names})).collect::<Vec<_>>(),
|
|
"prefs": prefs,
|
|
"memory": memory,
|
|
})))
|
|
}
|
|
|
|
/// A server skill with its supporting text files (templates).
|
|
pub async fn skill(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
|
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
|
let sk = crate::prompts::skills(&app, &root, &root).into_iter().find(|s| s.name == name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "skill not found".into()))?;
|
|
let dir = sk.path.parent().map(PathBuf::from).unwrap_or_default();
|
|
let content = std::fs::read_to_string(&sk.path)?;
|
|
let mut files = Vec::new();
|
|
for e in ignore::WalkBuilder::new(&dir).hidden(false).build().flatten() {
|
|
let p = e.path();
|
|
if !p.is_file() || p == sk.path || files.len() >= 50 {
|
|
continue;
|
|
}
|
|
if let Ok(text) = std::fs::read_to_string(p) {
|
|
if text.len() <= 200_000 {
|
|
files.push(json!({"path": p.strip_prefix(&dir).unwrap_or(p), "content": text}));
|
|
}
|
|
}
|
|
}
|
|
Ok(Json(json!({"name": sk.name, "description": sk.description, "content": content, "files": files})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LlmBody {
|
|
/// "provider/model"
|
|
model: String,
|
|
system: String,
|
|
messages: Vec<Message>,
|
|
#[serde(default)]
|
|
tools: Vec<ToolDef>,
|
|
max_tokens: Option<u64>,
|
|
#[serde(default)]
|
|
reasoning: bool,
|
|
effort: Option<String>,
|
|
/// Local session id (statistics group the requests by it).
|
|
session: Option<String>,
|
|
/// Mode / agent name (statistics).
|
|
agent: Option<String>,
|
|
}
|
|
|
|
/// Model gateway: one model request, streamed as SSE (`data: <json>`).
|
|
/// Events: text, reasoning, signature, tool_call, usage, done, error.
|
|
pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<LlmBody>) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
|
|
let (provider, mut model) = app.registry().resolve(&b.model).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
|
if let Some(e) = b.effort.filter(|e| !e.is_empty()) {
|
|
model.effort = Some(e);
|
|
}
|
|
let provider_name = b.model.split_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();
|
|
crate::budget::check(&app, &provider_name, Some(&user.id)).map_err(|e| ApiError(StatusCode::PAYMENT_REQUIRED, format!("{e:#}")))?;
|
|
let max_tokens = b.max_tokens.or(model.max_output).unwrap_or(16_000);
|
|
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
|
|
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning, session_id: Some(session.clone()) };
|
|
let agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
|
|
let spec = b.model.clone();
|
|
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
|
|
tokio::spawn(async move {
|
|
let slot = app.registry().acquire(&spec).await;
|
|
let started = std::time::Instant::now();
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(256);
|
|
let cancel = CancellationToken::new();
|
|
let c2 = cancel.clone();
|
|
let handle = tokio::spawn(async move {
|
|
let _slot = slot;
|
|
provider.stream(req, tx, c2).await
|
|
});
|
|
let mut usage = crate::types::Usage::default();
|
|
let mut ttft = None;
|
|
while let Some(ev) = rx.recv().await {
|
|
let v = match ev {
|
|
StreamEvent::TextDelta(t) => json!({"type": "text", "text": t}),
|
|
StreamEvent::ReasoningDelta(t) => json!({"type": "reasoning", "text": t}),
|
|
StreamEvent::ReasoningSignature(s) => json!({"type": "signature", "signature": s}),
|
|
StreamEvent::ToolCall { id, name, input, extra } => json!({"type": "tool_call", "id": id, "name": name, "input": input, "extra": extra}),
|
|
StreamEvent::Usage(u) => {
|
|
usage = u.clone();
|
|
json!({"type": "usage", "usage": u})
|
|
}
|
|
StreamEvent::Done { stop_reason } => json!({"type": "done", "stop_reason": stop_reason}),
|
|
};
|
|
if ttft.is_none() && v["type"] != "usage" {
|
|
ttft = Some(started.elapsed().as_millis() as u64);
|
|
}
|
|
if out_tx.send(Ok(SseEvent::default().data(v.to_string()))).await.is_err() {
|
|
// Client went away: stop the model request.
|
|
cancel.cancel();
|
|
break;
|
|
}
|
|
}
|
|
let result = handle.await;
|
|
let error = !matches!(result, Ok(Ok(())));
|
|
if let Ok(Err(e)) | Err(e) = result.map_err(anyhow::Error::from) {
|
|
let _ = out_tx.send(Ok(SseEvent::default().data(json!({"type": "error", "message": format!("{e:#}")}).to_string()))).await;
|
|
}
|
|
let (p, m) = spec.split_once('/').unwrap_or(("", spec.as_str()));
|
|
let _ = app.store.add_usage(&UsageRecord {
|
|
ts: now_ms(),
|
|
session_id: session.clone(),
|
|
root_session_id: session,
|
|
provider: p.into(),
|
|
model: m.into(),
|
|
agent,
|
|
input_tokens: usage.input_tokens,
|
|
output_tokens: usage.output_tokens,
|
|
cache_read_tokens: usage.cache_read_tokens,
|
|
cache_write_tokens: usage.cache_write_tokens,
|
|
ttft_ms: ttft.unwrap_or(0),
|
|
duration_ms: started.elapsed().as_millis() as u64,
|
|
cost: model.cost(&usage),
|
|
error,
|
|
user_id: Some(user.id.clone()),
|
|
allowance: usage.allowance,
|
|
});
|
|
});
|
|
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
|
|
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ToolBody {
|
|
name: String,
|
|
input: Value,
|
|
}
|
|
|
|
/// Run one of the server's MCP tools for the CLI (permission was asked locally).
|
|
pub async fn tool(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ToolBody>) -> ApiResult {
|
|
let cfg = app.config();
|
|
let (direct, deferred) = app.mcp.tools(&cfg, Some(&user));
|
|
let tool: std::sync::Arc<dyn crate::tools::Tool> = match b.name.as_str() {
|
|
"mcp_search" if !deferred.is_empty() => std::sync::Arc::new(crate::mcp::McpSearch),
|
|
"mcp_call" if !deferred.is_empty() => std::sync::Arc::new(crate::mcp::McpCall),
|
|
n => direct.into_iter().find(|t| t.name() == n).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no MCP tool '{n}' for you")))?,
|
|
};
|
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
|
let ctx = ToolCtx {
|
|
app: app.clone(),
|
|
session_id: format!("cli-{}", user.id),
|
|
call_id: crate::types::new_id(),
|
|
cwd: root.clone(),
|
|
cancel: CancellationToken::new(),
|
|
agent: "build".into(),
|
|
roots: app.user_roots(Some(&user.id)),
|
|
sandbox: crate::sandbox::spec_for(&app, Some(&user.id)),
|
|
clean_room: false,
|
|
owner_id: Some(user.id.clone()),
|
|
};
|
|
app.audit(Some(&user), "cli.mcp", &b.name, "");
|
|
match tool.run(b.input, &ctx).await {
|
|
Ok(out) => Ok(Json(json!({"content": out.content, "is_error": out.is_error, "images": out.images}))),
|
|
Err(e) => Ok(Json(json!({"content": format!("Error: {e:#}"), "is_error": true}))),
|
|
}
|
|
}
|
|
|
|
/// Interface preferences (theme) shared between web UI and CLI.
|
|
pub async fn get_prefs(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
|
let v: Value = app.store.user_setting(&user.id, "prefs")?;
|
|
Ok(Json(if v.is_null() { json!({}) } else { v }))
|
|
}
|
|
|
|
pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
|
|
if !b.is_object() || b.to_string().len() > 4000 {
|
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid preferences".into()));
|
|
}
|
|
app.store.set_user_setting(&user.id, "prefs", &b)?;
|
|
Ok(Json(b))
|
|
}
|
|
|
|
/// Personal memory (applies to all projects, web and CLI).
|
|
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
|
let m: String = app.store.user_setting(&user.id, "memory")?;
|
|
Ok(Json(json!({"memory": m})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct MemoryBody {
|
|
memory: String,
|
|
}
|
|
|
|
pub async fn put_memory(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<MemoryBody>) -> ApiResult {
|
|
if b.memory.len() > 20_000 {
|
|
return Err(ApiError(StatusCode::BAD_REQUEST, "the memory is limited to 20,000 characters".into()));
|
|
}
|
|
app.store.set_user_setting(&user.id, "memory", &b.memory)?;
|
|
Ok(Json(json!({"memory": b.memory})))
|
|
}
|
|
|
|
// --- prompt templates ---------------------------------------------------------
|
|
|
|
/// Owner id of the templates every user sees (edited by admins).
|
|
const GLOBAL_TEMPLATES: &str = "*";
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, Deserialize, Default)]
|
|
pub struct Template {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub text: String,
|
|
}
|
|
|
|
pub async fn get_templates(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
|
let personal: Vec<Template> = app.store.user_setting(&user.id, "templates")?;
|
|
let global: Vec<Template> = app.store.user_setting(GLOBAL_TEMPLATES, "templates")?;
|
|
Ok(Json(json!({"personal": personal, "global": global, "can_edit_global": user.is_admin()})))
|
|
}
|
|
|
|
pub async fn put_templates(Extension(user): Extension<User>, State(app): State<AppState>, Path(scope): Path<String>, Json(list): Json<Vec<Template>>) -> ApiResult {
|
|
let owner = match scope.as_str() {
|
|
"personal" => user.id.clone(),
|
|
"global" => {
|
|
crate::auth::require_admin(&user)?;
|
|
GLOBAL_TEMPLATES.to_string()
|
|
}
|
|
_ => return Err(ApiError(StatusCode::NOT_FOUND, "unknown scope".into())),
|
|
};
|
|
if list.len() > 200 || list.iter().any(|t| t.name.trim().is_empty() || t.name.len() > 100 || t.text.len() > 20_000) {
|
|
return Err(ApiError(StatusCode::BAD_REQUEST, "templates need a name (max. 100 characters) and at most 20,000 characters of text".into()));
|
|
}
|
|
let list: Vec<Template> = list
|
|
.into_iter()
|
|
.map(|mut t| {
|
|
if t.id.is_empty() {
|
|
t.id = uuid::Uuid::new_v4().simple().to_string();
|
|
}
|
|
t.name = t.name.trim().to_string();
|
|
t
|
|
})
|
|
.collect();
|
|
app.store.set_user_setting(&owner, "templates", &list)?;
|
|
Ok(Json(json!(list)))
|
|
}
|