Users, file manager, image generation, budgets and agent workflow

- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+3
View File
@@ -165,6 +165,9 @@ impl Provider for Anthropic {
body["output_config"] = json!({"effort": effort});
}
for (k, v) in &req.model.options {
body[k] = v.clone();
}
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
+38 -1
View File
@@ -105,6 +105,13 @@ pub struct ProviderEntry {
#[derive(Default)]
pub struct Registry {
pub providers: BTreeMap<String, ProviderEntry>,
/// Concurrency limits keyed by "provider" and "provider/model".
limits: BTreeMap<String, Arc<tokio::sync::Semaphore>>,
}
/// Held while a request runs; dropping it frees the slot(s).
pub struct Slot {
_permits: Vec<tokio::sync::OwnedSemaphorePermit>,
}
pub fn http_client() -> reqwest::Client {
@@ -121,6 +128,7 @@ impl Registry {
pub async fn build(cfg: &Config) -> Self {
let client = http_client();
let mut providers = BTreeMap::new();
let mut limits = BTreeMap::new();
for (name, pc) in &cfg.providers {
if !pc.enabled {
continue;
@@ -131,6 +139,14 @@ impl Registry {
ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)),
};
let mut models: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
if let Some(n) = pc.max_concurrency.filter(|n| *n > 0) {
limits.insert(name.clone(), Arc::new(tokio::sync::Semaphore::new(n)));
}
for m in &models {
if let Some(n) = m.max_concurrency.filter(|n| *n > 0) {
limits.insert(format!("{name}/{}", m.id), Arc::new(tokio::sync::Semaphore::new(n)));
}
}
if models.is_empty() {
match tokio::time::timeout(std::time::Duration::from_secs(15), provider.list_models()).await {
Ok(Ok(m)) => models = m,
@@ -143,7 +159,28 @@ impl Registry {
ProviderEntry { name: name.clone(), provider, models },
);
}
Self { providers }
Self { providers, limits }
}
/// Wait for a free slot on the model and its provider. Always acquired in the
/// same order (model, then provider), so concurrent requests cannot deadlock.
pub async fn acquire(&self, spec: &str) -> Slot {
let provider = spec.split_once('/').map(|(p, _)| p).unwrap_or(spec);
let mut permits = Vec::new();
for key in [spec, provider] {
if let Some(sem) = self.limits.get(key) {
if let Ok(p) = sem.clone().acquire_owned().await {
permits.push(p);
}
}
}
Slot { _permits: permits }
}
/// True if a request to this model would have to wait.
pub fn is_saturated(&self, spec: &str) -> bool {
let provider = spec.split_once('/').map(|(p, _)| p).unwrap_or(spec);
[spec, provider].iter().any(|k| self.limits.get(*k).is_some_and(|s| s.available_permits() == 0))
}
/// Resolve "provider/model". Unknown models of a known provider are allowed
+93 -1
View File
@@ -107,6 +107,55 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
out
}
/// Splits streamed content into text and inline `<think>…</think>` reasoning.
/// Tags may be split across chunks, so a possible partial tag is held back.
#[derive(Default)]
pub struct ThinkSplitter {
in_think: bool,
pending: String,
}
const OPEN: &str = "<think>";
const CLOSE: &str = "</think>";
impl ThinkSplitter {
/// Returns (is_reasoning, text) pieces that are safe to emit.
pub fn push(&mut self, chunk: &str) -> Vec<(bool, String)> {
self.pending.push_str(chunk);
let mut out = Vec::new();
loop {
let tag = if self.in_think { CLOSE } else { OPEN };
if let Some(pos) = self.pending.find(tag) {
let before: String = self.pending[..pos].to_string();
if !before.is_empty() {
out.push((self.in_think, before));
}
self.pending.drain(..pos + tag.len());
self.in_think = !self.in_think;
continue;
}
// Keep a trailing prefix of the tag (e.g. "<thi") until the next chunk.
let keep = (1..tag.len()).rev().find(|n| self.pending.ends_with(&tag[..*n])).unwrap_or(0);
let emit_len = self.pending.len() - keep;
if emit_len > 0 {
let text: String = self.pending.drain(..emit_len).collect();
out.push((self.in_think, text));
}
break;
}
// Leading whitespace after </think> is noise.
out.retain(|(_, t)| !t.is_empty());
out
}
pub fn finish(&mut self) -> Vec<(bool, String)> {
if self.pending.is_empty() {
return vec![];
}
vec![(self.in_think, std::mem::take(&mut self.pending))]
}
}
#[derive(Default)]
struct PendingCall {
id: String,
@@ -145,6 +194,9 @@ impl Provider for OpenAi {
body["reasoning_effort"] = json!(effort);
}
for (k, v) in &req.model.options {
body[k] = v.clone();
}
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
let resp = self.ep.apply(rb).send().await?;
if !resp.status().is_success() {
@@ -153,6 +205,7 @@ impl Provider for OpenAi {
let mut events = resp.bytes_stream().eventsource();
let mut calls: BTreeMap<u64, PendingCall> = BTreeMap::new();
let mut think = ThinkSplitter::default();
let mut usage = Usage::default();
let mut stop_reason = String::from("stop");
@@ -182,7 +235,11 @@ impl Provider for OpenAi {
let Some(choice) = data["choices"].get(0) else { continue };
let d = &choice["delta"];
if let Some(t) = d["content"].as_str().filter(|t| !t.is_empty()) {
tx.send(StreamEvent::TextDelta(t.into())).await.ok();
// Models served by llama.cpp & co. may inline their reasoning as <think>…</think>.
for (is_reasoning, chunk) in think.push(t) {
let ev = if is_reasoning { StreamEvent::ReasoningDelta(chunk) } else { StreamEvent::TextDelta(chunk) };
tx.send(ev).await.ok();
}
}
let r = d["reasoning_content"].as_str().or_else(|| d["reasoning"].as_str());
if let Some(r) = r.filter(|r| !r.is_empty()) {
@@ -211,6 +268,10 @@ impl Provider for OpenAi {
}
}
for (is_reasoning, chunk) in think.finish() {
let ev = if is_reasoning { StreamEvent::ReasoningDelta(chunk) } else { StreamEvent::TextDelta(chunk) };
tx.send(ev).await.ok();
}
for (i, pc) in calls {
let input = if pc.args.trim().is_empty() {
json!({})
@@ -249,3 +310,34 @@ impl Provider for OpenAi {
Ok(models)
}
}
#[cfg(test)]
mod tests {
use super::ThinkSplitter;
fn run(chunks: &[&str]) -> (String, String) {
let mut s = ThinkSplitter::default();
let mut text = String::new();
let mut reasoning = String::new();
let mut parts: Vec<(bool, String)> = chunks.iter().flat_map(|c| s.push(c)).collect();
parts.extend(s.finish());
for (r, t) in parts {
if r { reasoning.push_str(&t) } else { text.push_str(&t) }
}
(text, reasoning)
}
#[test]
fn splits_think_tags_across_chunks() {
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
assert_eq!(reasoning, "plan the fix");
assert_eq!(text, "Here is the answer.");
}
#[test]
fn plain_text_and_lookalikes_pass_through() {
let (text, reasoning) = run(&["a < b and <thin", "g> stays"]);
assert_eq!(text, "a < b and <thing> stays");
assert!(reasoning.is_empty());
}
}