Add file badges, menu icons, LSP diagnostics, robust compaction
- Files in chat: preview with type/size badges and file name; attachment chips - Menu and settings tabs with icons - Heatmap: distinct colour for empty hours, hourly labels - Context chip shows used/total tokens (e.g. 221k/240k) plus percent - LSP client: diagnostics appended to edit/write results and a diagnostics tool (typescript-language-server, pyright, rust-analyzer, gopls, clangd; sandboxed) - Compaction summarizes a plain transcript and retries once on empty output - files/stat endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b569b6835
commit
ec1b9b4925
22 files changed
+1036
-48
No files matched your search
@@ -29,6 +29,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
|
||||
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
|
||||
# installed by agents: rustup component add rust-analyzer, go install …).
|
||||
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
|
||||
COPY --from=server /src/server/target/release/codecraft /usr/local/bin/codecraft
|
||||
COPY --from=web /web/dist /app/web
|
||||
COPY server/ghidra /app/ghidra
|
||||
|
||||
@@ -182,6 +182,7 @@ bash_output = "allow"
|
||||
kill_shell = "allow"
|
||||
web_fetch = "ask"
|
||||
web_search = "allow"
|
||||
diagnostics = "allow"
|
||||
browser_snapshot = "allow"
|
||||
image_compare = "allow"
|
||||
generate_image = "ask" # costs money per image
|
||||
@@ -299,6 +300,23 @@ url = ""
|
||||
api_key = ""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Language servers (diagnostics after edits, tool "diagnostics")
|
||||
# -----------------------------------------------------------------------------
|
||||
# Built in: rust (rust-analyzer), typescript (typescript-language-server),
|
||||
# python (pyright-langserver), go (gopls), c (clangd) – used when installed.
|
||||
# TypeScript and Python servers ship with the image. Servers run in the
|
||||
# user's sandbox and stop after 15 minutes without use.
|
||||
# -----------------------------------------------------------------------------
|
||||
[lsp]
|
||||
enabled = true
|
||||
# Add or override a server:
|
||||
# [lsp.servers.zig]
|
||||
# command = "zls"
|
||||
# extensions = ["zig"]
|
||||
# root_markers = ["build.zig"]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sandbox
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -92,6 +92,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/files/list", get(crate::files::list))
|
||||
.route("/files/read", get(crate::files::read))
|
||||
.route("/files/raw", get(crate::files::raw))
|
||||
.route("/files/stat", get(crate::files::stat))
|
||||
.route("/files/write", put(crate::files::write))
|
||||
.route("/files/create", post(crate::files::create))
|
||||
.route("/files/move", post(crate::files::move_or_copy))
|
||||
|
||||
@@ -69,6 +69,8 @@ pub struct App {
|
||||
pub tool_install: crate::reverse::ToolInstall,
|
||||
/// Background shells of all sessions.
|
||||
pub shells: crate::shells::Shells,
|
||||
/// Language servers (diagnostics after edits).
|
||||
pub lsp: crate::lsp::Lsp,
|
||||
}
|
||||
|
||||
/// What a user may do with a session.
|
||||
@@ -105,6 +107,7 @@ impl App {
|
||||
login_failures: Mutex::default(),
|
||||
tool_install: Mutex::default(),
|
||||
shells: Mutex::default(),
|
||||
lsp: Default::default(),
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
|
||||
@@ -23,8 +23,43 @@ pub struct Config {
|
||||
pub images: ImagesConfig,
|
||||
pub sandbox: SandboxConfig,
|
||||
pub search: SearchConfig,
|
||||
pub lsp: LspConfig,
|
||||
}
|
||||
|
||||
/// Language servers for diagnostics after edits.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct LspConfig {
|
||||
pub enabled: bool,
|
||||
/// Extra or overridden servers (built-in: rust, typescript, python, go, c).
|
||||
pub servers: std::collections::BTreeMap<String, LspServer>,
|
||||
}
|
||||
|
||||
impl Default for LspConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, servers: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct LspServer {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// File extensions without dot.
|
||||
#[serde(default)]
|
||||
pub extensions: Vec<String>,
|
||||
/// Files that mark the project root (e.g. Cargo.toml).
|
||||
#[serde(default)]
|
||||
pub root_markers: Vec<String>,
|
||||
/// initializationOptions sent to the server.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub init_options: serde_json::Value,
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
/// Web search for the web_search tool.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
|
||||
@@ -259,6 +259,19 @@ pub async fn raw(Extension(user): Extension<User>, State(app): State<AppState>,
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StatQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Size, type and modification time of a file (captions in the chat).
|
||||
pub async fn stat(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<StatQuery>) -> ApiResult {
|
||||
let p = checked_path(&app, &user, &q.path, true)?;
|
||||
let m = tokio::fs::metadata(&p).await?;
|
||||
let mtime = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
Ok(Json(json!({"size": m.len(), "dir": m.is_dir(), "mime": mime_for(&p), "mtime": mtime})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WriteBody {
|
||||
path: String,
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Minimal Language Server Protocol client for diagnostics after edits.
|
||||
//!
|
||||
//! Language servers are started on demand per (user, server, project root),
|
||||
//! run in the user's sandbox like shell commands, and are stopped after a
|
||||
//! while without use. After the agent changes a file, the file is synced to
|
||||
//! the server and the errors it reports are appended to the tool result.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::LspServer;
|
||||
use crate::sandbox::SandboxSpec;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin};
|
||||
use tokio::sync::{Notify, oneshot};
|
||||
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||
const MAX_SHOWN: usize = 20;
|
||||
|
||||
/// Built-in servers (used when the config defines none).
|
||||
pub fn default_servers() -> Vec<(String, LspServer)> {
|
||||
let s = |cmd: &str, args: &[&str], ext: &[&str], roots: &[&str]| LspServer {
|
||||
command: cmd.into(),
|
||||
args: args.iter().map(|a| a.to_string()).collect(),
|
||||
extensions: ext.iter().map(|a| a.to_string()).collect(),
|
||||
root_markers: roots.iter().map(|a| a.to_string()).collect(),
|
||||
init_options: Value::Null,
|
||||
enabled: true,
|
||||
};
|
||||
// Global TypeScript as fallback when the project has none in node_modules.
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let ts_lib = ["/usr/local/lib/node_modules/typescript/lib".to_string(), format!("{home}/.npm-global/lib/node_modules/typescript/lib")]
|
||||
.into_iter()
|
||||
.find(|p| Path::new(p).is_dir());
|
||||
let mut ts = s("typescript-language-server", &["--stdio"], &["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"], &["tsconfig.json", "jsconfig.json", "package.json"]);
|
||||
if let Some(lib) = ts_lib {
|
||||
ts.init_options = json!({"tsserver": {"fallbackPath": lib}});
|
||||
}
|
||||
vec![
|
||||
("rust".into(), s("rust-analyzer", &[], &["rs"], &["Cargo.toml"])),
|
||||
("typescript".into(), ts),
|
||||
("python".into(), s("pyright-langserver", &["--stdio"], &["py", "pyi"], &["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pyrightconfig.json"])),
|
||||
("go".into(), s("gopls", &[], &["go"], &["go.mod"])),
|
||||
("c".into(), s("clangd", &[], &["c", "h", "cc", "cpp", "cxx", "hpp", "hh"], &["compile_commands.json", "CMakeLists.txt", "Makefile"])),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Diagnostic {
|
||||
pub line: u64,
|
||||
pub col: u64,
|
||||
pub severity: u64,
|
||||
pub message: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
struct Client {
|
||||
stdin: tokio::sync::Mutex<ChildStdin>,
|
||||
child: Mutex<Option<Child>>,
|
||||
next_id: AtomicI64,
|
||||
pending: Mutex<HashMap<i64, oneshot::Sender<Value>>>,
|
||||
/// uri -> (publish counter, diagnostics)
|
||||
diags: Mutex<HashMap<String, (u64, Vec<Diagnostic>)>>,
|
||||
published: Notify,
|
||||
/// uri -> version of opened documents
|
||||
open: Mutex<HashMap<String, i64>>,
|
||||
last_used: Mutex<Instant>,
|
||||
dead: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl Drop for Client {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut c) = self.child.lock().unwrap().take() {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Lsp {
|
||||
clients: Mutex<HashMap<String, Arc<Client>>>,
|
||||
/// Servers that failed to start (key -> time), not retried for a while.
|
||||
failed: Mutex<HashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
fn file_uri(p: &Path) -> String {
|
||||
let mut s = String::from("file://");
|
||||
for b in p.display().to_string().bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' => s.push(b as char),
|
||||
_ => s.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn language_id(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"rs" => "rust",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" => "typescriptreact",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"jsx" => "javascriptreact",
|
||||
"py" | "pyi" => "python",
|
||||
"go" => "go",
|
||||
"c" | "h" => "c",
|
||||
_ => "cpp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a command on the PATH the process will see (sandbox home or server home).
|
||||
fn find_command(cmd: &str, sandbox: Option<&SandboxSpec>) -> Option<PathBuf> {
|
||||
if cmd.contains('/') {
|
||||
return Path::new(cmd).exists().then(|| PathBuf::from(cmd));
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
match sandbox {
|
||||
Some(s) => {
|
||||
for d in [".local/bin", ".cargo/bin", ".npm-global/bin", "go/bin"] {
|
||||
dirs.push(s.home.join(d));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Ok(p) = std::env::var("PATH") {
|
||||
dirs.extend(std::env::split_paths(&p));
|
||||
}
|
||||
if let Ok(h) = std::env::var("HOME") {
|
||||
dirs.push(Path::new(&h).join("go/bin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs.extend(["/usr/local/bin", "/usr/bin", "/bin"].map(PathBuf::from));
|
||||
dirs.into_iter().map(|d| d.join(cmd)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Nearest directory (from the file upwards, not above `limit`) containing a marker.
|
||||
fn project_root(file: &Path, markers: &[String], limit: &Path) -> PathBuf {
|
||||
let mut dir = file.parent();
|
||||
while let Some(d) = dir {
|
||||
if markers.iter().any(|m| d.join(m).exists()) {
|
||||
return d.to_path_buf();
|
||||
}
|
||||
if d == limit || !d.starts_with(limit) {
|
||||
break;
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
if file.starts_with(limit) { limit.to_path_buf() } else { file.parent().unwrap_or(limit).to_path_buf() }
|
||||
}
|
||||
|
||||
async fn write_msg(stdin: &tokio::sync::Mutex<ChildStdin>, msg: &Value) -> Result<()> {
|
||||
let body = serde_json::to_vec(msg)?;
|
||||
let mut w = stdin.lock().await;
|
||||
w.write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()).await?;
|
||||
w.write_all(&body).await?;
|
||||
w.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Client {
|
||||
async fn notify(&self, method: &str, params: Value) -> Result<()> {
|
||||
write_msg(&self.stdin, &json!({"jsonrpc": "2.0", "method": method, "params": params})).await
|
||||
}
|
||||
|
||||
async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result<Value> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().unwrap().insert(id, tx);
|
||||
write_msg(&self.stdin, &json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).await?;
|
||||
let v = tokio::time::timeout(timeout, rx).await.map_err(|_| anyhow!("{method}: no answer"))??;
|
||||
if let Some(e) = v.get("error") {
|
||||
bail!("{method}: {e}");
|
||||
}
|
||||
Ok(v["result"].clone())
|
||||
}
|
||||
|
||||
fn handle(self: &Arc<Self>, msg: Value) {
|
||||
let method = msg["method"].as_str().unwrap_or("");
|
||||
if msg.get("id").is_some() && !method.is_empty() {
|
||||
// Request from the server: answer the few that need it.
|
||||
let result = match method {
|
||||
"workspace/configuration" => Value::Array(vec![Value::Null; msg["params"]["items"].as_array().map_or(0, |a| a.len())]),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let me = self.clone();
|
||||
let id = msg["id"].clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = write_msg(&me.stdin, &json!({"jsonrpc": "2.0", "id": id, "result": result})).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Some(id) = msg["id"].as_i64() {
|
||||
if let Some(tx) = self.pending.lock().unwrap().remove(&id) {
|
||||
let _ = tx.send(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if method == "textDocument/publishDiagnostics" {
|
||||
let uri = msg["params"]["uri"].as_str().unwrap_or("").to_string();
|
||||
let list = msg["params"]["diagnostics"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|d| Diagnostic {
|
||||
line: d["range"]["start"]["line"].as_u64().unwrap_or(0) + 1,
|
||||
col: d["range"]["start"]["character"].as_u64().unwrap_or(0) + 1,
|
||||
severity: d["severity"].as_u64().unwrap_or(1),
|
||||
message: d["message"].as_str().unwrap_or("").to_string(),
|
||||
source: d["source"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut diags = self.diags.lock().unwrap();
|
||||
let n = diags.get(&uri).map_or(0, |e| e.0) + 1;
|
||||
diags.insert(uri, (n, list));
|
||||
drop(diags);
|
||||
self.published.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_count(&self, uri: &str) -> u64 {
|
||||
self.diags.lock().unwrap().get(uri).map_or(0, |e| e.0)
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(app: &App, server: &LspServer, root: &Path, sandbox: Option<&SandboxSpec>) -> Result<Arc<Client>> {
|
||||
let program = find_command(&server.command, sandbox).ok_or_else(|| anyhow!("{} is not installed", server.command))?;
|
||||
let program = program.display().to_string();
|
||||
let args: Vec<&str> = server.args.iter().map(String::as_str).collect();
|
||||
let mut cmd = match sandbox {
|
||||
Some(spec) => {
|
||||
if !crate::sandbox::available() {
|
||||
bail!("sandbox unavailable");
|
||||
}
|
||||
// Inside the sandbox the home directory is mounted elsewhere.
|
||||
let inside = program.replace(&spec.home.display().to_string(), &spec.home_inside());
|
||||
crate::sandbox::command(spec, root, &inside, &args)
|
||||
}
|
||||
None => {
|
||||
let mut c = tokio::process::Command::new(&program);
|
||||
c.args(&args).current_dir(root);
|
||||
c
|
||||
}
|
||||
};
|
||||
let _ = app;
|
||||
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
|
||||
let mut child = cmd.spawn().with_context(|| format!("starting {}", server.command))?;
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let client = Arc::new(Client {
|
||||
stdin: tokio::sync::Mutex::new(stdin),
|
||||
child: Mutex::new(Some(child)),
|
||||
next_id: AtomicI64::new(1),
|
||||
pending: Mutex::default(),
|
||||
diags: Mutex::default(),
|
||||
published: Notify::new(),
|
||||
open: Mutex::default(),
|
||||
last_used: Mutex::new(Instant::now()),
|
||||
dead: false.into(),
|
||||
});
|
||||
|
||||
// Reader: Content-Length framed JSON messages.
|
||||
let weak = Arc::downgrade(&client);
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
let mut len = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) | Err(_) => {
|
||||
if let Some(c) = weak.upgrade() {
|
||||
c.dead.store(true, Ordering::SeqCst);
|
||||
c.published.notify_waiters();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(v) = line.strip_prefix("Content-Length:") {
|
||||
len = v.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
if len == 0 || len > 64 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
if reader.read_exact(&mut buf).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let Some(c) = weak.upgrade() else { return };
|
||||
if let Ok(msg) = serde_json::from_slice::<Value>(&buf) {
|
||||
c.handle(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let root_uri = file_uri(root);
|
||||
client
|
||||
.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"processId": null,
|
||||
"rootUri": root_uri,
|
||||
"rootPath": root.display().to_string(),
|
||||
"workspaceFolders": [{"uri": root_uri, "name": root.file_name().and_then(|n| n.to_str()).unwrap_or("project")}],
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"synchronization": {"didSave": true, "dynamicRegistration": false},
|
||||
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true}
|
||||
},
|
||||
"workspace": {"configuration": true, "workspaceFolders": true}
|
||||
},
|
||||
"initializationOptions": if server.init_options.is_null() { json!({}) } else { server.init_options.clone() }
|
||||
}),
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.await?;
|
||||
client.notify("initialized", json!({})).await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
impl Lsp {
|
||||
/// Stop servers that were not used for a while.
|
||||
pub fn reap(&self) {
|
||||
self.clients.lock().unwrap().retain(|_, c| c.last_used.lock().unwrap().elapsed() < IDLE_TIMEOUT && !c.dead.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured servers (config, else built-in defaults).
|
||||
fn servers(app: &App) -> Vec<(String, LspServer)> {
|
||||
let cfg = app.config();
|
||||
if !cfg.lsp.enabled {
|
||||
return vec![];
|
||||
}
|
||||
let mut list = default_servers();
|
||||
for (name, s) in &cfg.lsp.servers {
|
||||
list.retain(|(n, _)| n != name);
|
||||
list.push((name.clone(), s.clone()));
|
||||
}
|
||||
list.retain(|(_, s)| s.enabled);
|
||||
list
|
||||
}
|
||||
|
||||
/// Sync a file to its language server and return the diagnostics it reports.
|
||||
/// `None` when no server handles the file (or it is not installed).
|
||||
pub async fn diagnostics(app: &Arc<App>, file: &Path, limit: &Path, sandbox: Option<&SandboxSpec>, wait: Duration) -> Option<Result<Vec<Diagnostic>>> {
|
||||
let ext = file.extension()?.to_str()?.to_ascii_lowercase();
|
||||
let (name, server) = servers(app).into_iter().find(|(_, s)| s.extensions.iter().any(|e| e.eq_ignore_ascii_case(&ext)))?;
|
||||
let root = project_root(file, &server.root_markers, limit);
|
||||
let user = sandbox.map(|s| s.username.as_str()).unwrap_or("-");
|
||||
let key = format!("{user}|{name}|{}", root.display());
|
||||
|
||||
if let Some(t) = app.lsp.failed.lock().unwrap().get(&key) {
|
||||
if t.elapsed() < Duration::from_secs(300) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let existing = app.lsp.clients.lock().unwrap().get(&key).cloned().filter(|c| !c.dead.load(Ordering::SeqCst));
|
||||
let (client, fresh) = match existing {
|
||||
Some(c) => (c, false),
|
||||
None => match start(app, &server, &root, sandbox).await {
|
||||
Ok(c) => {
|
||||
app.lsp.clients.lock().unwrap().insert(key.clone(), c.clone());
|
||||
(c, true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("lsp {name}: {e:#}");
|
||||
app.lsp.failed.lock().unwrap().insert(key, Instant::now());
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
*client.last_used.lock().unwrap() = Instant::now();
|
||||
Some(sync_and_wait(&client, file, &ext, if fresh { wait.max(Duration::from_secs(20)) } else { wait }).await)
|
||||
}
|
||||
|
||||
async fn sync_and_wait(client: &Arc<Client>, file: &Path, ext: &str, wait: Duration) -> Result<Vec<Diagnostic>> {
|
||||
let text = tokio::fs::read_to_string(file).await.unwrap_or_default();
|
||||
let uri = file_uri(file);
|
||||
let before = client.publish_count(&uri);
|
||||
let version = {
|
||||
let mut open = client.open.lock().unwrap();
|
||||
let v = open.get(&uri).map_or(1, |v| v + 1);
|
||||
open.insert(uri.clone(), v);
|
||||
v
|
||||
};
|
||||
if version == 1 {
|
||||
client
|
||||
.notify("textDocument/didOpen", json!({"textDocument": {"uri": uri, "languageId": language_id(ext), "version": 1, "text": text}}))
|
||||
.await?;
|
||||
} else {
|
||||
client
|
||||
.notify("textDocument/didChange", json!({"textDocument": {"uri": uri, "version": version}, "contentChanges": [{"text": text}]}))
|
||||
.await?;
|
||||
}
|
||||
client.notify("textDocument/didSave", json!({"textDocument": {"uri": uri}, "text": text})).await?;
|
||||
|
||||
// Wait for a fresh publish; servers often send an early empty one and the
|
||||
// real result shortly after, so keep listening briefly after the first.
|
||||
let deadline = Instant::now() + wait;
|
||||
let mut settle: Option<Instant> = None;
|
||||
loop {
|
||||
if client.dead.load(Ordering::SeqCst) {
|
||||
bail!("language server exited");
|
||||
}
|
||||
let now = Instant::now();
|
||||
let count = client.publish_count(&uri);
|
||||
if count > before && settle.is_none() {
|
||||
settle = Some(now + Duration::from_millis(700));
|
||||
}
|
||||
let until = settle.map_or(deadline, |s| s.min(deadline));
|
||||
if now >= until {
|
||||
break;
|
||||
}
|
||||
let _ = tokio::time::timeout(until - now, client.published.notified()).await;
|
||||
if let Some(s) = settle {
|
||||
if client.publish_count(&uri) > count {
|
||||
settle = Some(Instant::now().max(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
if client.publish_count(&uri) == before {
|
||||
bail!("no diagnostics within {}s (server still indexing?)", wait.as_secs());
|
||||
}
|
||||
Ok(client.diags.lock().unwrap().get(&uri).map(|e| e.1.clone()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Format errors (and a few warnings) for a tool result.
|
||||
pub fn format(file: &str, diags: &[Diagnostic], warnings: bool) -> String {
|
||||
let mut list: Vec<&Diagnostic> = diags.iter().filter(|d| d.severity == 1 || (warnings && d.severity == 2)).collect();
|
||||
list.sort_by_key(|d| (d.severity, d.line, d.col));
|
||||
if list.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let errors = diags.iter().filter(|d| d.severity == 1).count();
|
||||
let mut out = format!("\n\n<diagnostics file=\"{file}\" errors=\"{errors}\">\n");
|
||||
for d in list.iter().take(MAX_SHOWN) {
|
||||
let sev = if d.severity == 1 { "error" } else { "warning" };
|
||||
let src = if d.source.is_empty() { String::new() } else { format!(" [{}]", d.source) };
|
||||
out.push_str(&format!("{}:{} {sev}{src}: {}\n", d.line, d.col, d.message.lines().next().unwrap_or("")));
|
||||
}
|
||||
if list.len() > MAX_SHOWN {
|
||||
out.push_str(&format!("… {} more\n", list.len() - MAX_SHOWN));
|
||||
}
|
||||
out.push_str("</diagnostics>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Background task: stop idle servers.
|
||||
pub fn spawn_reaper(app: Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
app.lsp.reap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tool: diagnostics of a file on demand.
|
||||
pub struct Diagnostics;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::tools::Tool for Diagnostics {
|
||||
fn name(&self) -> &str {
|
||||
"diagnostics"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Get compiler/linter diagnostics (errors and warnings) for a source file from its language server \
|
||||
(rust-analyzer, typescript-language-server, pyright, gopls, clangd – if installed). Edits already \
|
||||
report new errors automatically; use this to check a file you did not edit."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("Diagnostics {}", input["path"].as_str().unwrap_or(""))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &crate::tools::ToolCtx) -> Result<crate::tools::ToolOutput> {
|
||||
let p = crate::tools::arg_str(&input, "path")?;
|
||||
let path = ctx.resolve_read(p)?;
|
||||
match diagnostics(&ctx.app, &path, &ctx.cwd, ctx.sandbox.as_ref(), Duration::from_secs(10)).await {
|
||||
None => Ok(crate::tools::ToolOutput::text(
|
||||
"No language server available for this file type. Install one (e.g. `npm i -g typescript-language-server typescript`, \
|
||||
`npm i -g pyright`, `rustup component add rust-analyzer`, `go install golang.org/x/tools/gopls@latest`).",
|
||||
)),
|
||||
Some(Err(e)) => Ok(crate::tools::ToolOutput::error(format!("Language server: {e:#}"))),
|
||||
Some(Ok(d)) => {
|
||||
let s = format(p, &d, true);
|
||||
Ok(crate::tools::ToolOutput::text(if s.is_empty() { "No errors or warnings.".to_string() } else { s.trim().to_string() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod docs;
|
||||
mod files;
|
||||
mod git;
|
||||
mod images;
|
||||
mod lsp;
|
||||
mod mcp;
|
||||
mod permission;
|
||||
mod prompts;
|
||||
@@ -42,6 +43,7 @@ async fn main() -> Result<()> {
|
||||
let _ = sandbox::probe().await;
|
||||
let bind = cfg.server.bind.clone();
|
||||
let app = app::App::new(path, cfg).await?;
|
||||
lsp::spawn_reaper(app.clone());
|
||||
let router = api::router(app);
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
tracing::info!("listening on http://{bind}");
|
||||
|
||||
+87
-19
@@ -462,12 +462,26 @@ async fn execute_call(
|
||||
}
|
||||
}
|
||||
}
|
||||
let input_path = input["path"].as_str().map(String::from);
|
||||
let res = if name == "task" {
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
} else {
|
||||
tool.run(input, &ctx).await
|
||||
};
|
||||
// Diagnostics: report new errors of the changed file right away.
|
||||
let res = match res {
|
||||
Ok(mut out) if tool.is_edit() && !out.is_error => {
|
||||
if let Some(path) = input_path.as_deref().and_then(|p| ctx.resolve(p).ok()) {
|
||||
let wait = std::time::Duration::from_secs(4);
|
||||
if let Some(Ok(d)) = crate::lsp::diagnostics(app, &path, &ctx.cwd, ctx.sandbox.as_ref(), wait).await {
|
||||
out.content.push_str(&crate::lsp::format(input_path.as_deref().unwrap_or(""), &d, false));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
match res {
|
||||
Ok(out) => Part::ToolResult {
|
||||
call_id: call_id.into(),
|
||||
@@ -860,6 +874,54 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||
|
||||
const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points.";
|
||||
|
||||
/// Remove a leading `<think>…</think>` block some models put into the text.
|
||||
fn strip_think(text: &str) -> String {
|
||||
let t = text.trim_start();
|
||||
if t.starts_with("<think>") {
|
||||
if let Some(end) = t.find("</think>") {
|
||||
return t[end + 8..].trim().to_string();
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
text.trim().to_string()
|
||||
}
|
||||
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{} […{} more bytes]", &s[..end], s.len() - end)
|
||||
}
|
||||
|
||||
/// The history as readable text for summarizing (tool output shortened).
|
||||
fn transcript_of(history: &[Message]) -> String {
|
||||
let mut out = String::new();
|
||||
for m in history {
|
||||
let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||
let mut body = String::new();
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } => body.push_str(&format!("{text}\n")),
|
||||
Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
||||
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
|
||||
Part::ToolResult { name, content, is_error, .. } => {
|
||||
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
|
||||
}
|
||||
Part::Image { .. } => body.push_str("[image]\n"),
|
||||
Part::Reasoning { .. } => {}
|
||||
}
|
||||
}
|
||||
if !body.trim().is_empty() {
|
||||
out.push_str(&format!("### {role}\n{}\n", body.trim_end()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the active history with a summary (the old messages stay visible in the UI).
|
||||
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<()> {
|
||||
let session = app.session(session_id)?;
|
||||
@@ -869,27 +931,33 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
}
|
||||
let spec = session.model.clone();
|
||||
let (provider, model) = app.registry().resolve(&spec)?;
|
||||
let mut messages = history;
|
||||
// Strip images to keep the summary request small.
|
||||
for m in &mut messages {
|
||||
m.parts.retain(|p| !matches!(p, Part::Image { .. }));
|
||||
for p in &mut m.parts {
|
||||
if let Part::ToolResult { images, .. } = p {
|
||||
images.clear();
|
||||
}
|
||||
// The history goes in as a plain transcript: with real tool calls in the
|
||||
// history (and no tools offered) some models answer with a tool call or
|
||||
// only think, leaving the summary empty.
|
||||
let transcript = transcript_of(&history);
|
||||
let mut summary = String::new();
|
||||
for attempt in 0..2 {
|
||||
let prompt = if attempt == 0 {
|
||||
format!("<transcript>\n{transcript}\n</transcript>\n\n{COMPACT_PROMPT}")
|
||||
} else {
|
||||
format!("<transcript>\n{transcript}\n</transcript>\n\n{COMPACT_PROMPT}\n\nAnswer directly with the summary as plain Markdown text. Do not call tools.")
|
||||
};
|
||||
let req = ChatRequest {
|
||||
system: "You are a precise summarizer of software engineering sessions. You never call tools; you only write the requested summary.".into(),
|
||||
messages: vec![Message::new(Role::User, vec![Part::Text { text: prompt }])],
|
||||
tools: vec![],
|
||||
max_tokens: 16_000,
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "compaction");
|
||||
summary = strip_think(&text);
|
||||
if !summary.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
tracing::warn!("compaction of {session_id}: empty summary (attempt {})", attempt + 1);
|
||||
}
|
||||
messages.push(Message::new(Role::User, vec![Part::Text { text: COMPACT_PROMPT.into() }]));
|
||||
let req = ChatRequest {
|
||||
system: "You are a precise summarizer of software engineering sessions.".into(),
|
||||
messages,
|
||||
tools: vec![],
|
||||
max_tokens: 16_000,
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
let (summary, usage) = complete_text(&app, &spec, provider, req, cancel).await?;
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "compaction");
|
||||
if summary.trim().is_empty() {
|
||||
bail!("compaction produced an empty summary");
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(crate::shells::KillShell),
|
||||
Arc::new(web::WebFetch),
|
||||
Arc::new(web::WebSearch),
|
||||
Arc::new(crate::lsp::Diagnostics),
|
||||
Arc::new(agent::TodoWrite),
|
||||
Arc::new(agent::AskUser),
|
||||
Arc::new(agent::Task),
|
||||
|
||||
@@ -201,7 +201,9 @@ export function Heatmap({
|
||||
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
|
||||
<svg height={top + cell * 7 + 4} role="img" aria-label={t("stats.heatmap")}>
|
||||
<g className="axis">
|
||||
{[0, 3, 6, 9, 12, 15, 18, 21].map((h) => (
|
||||
{Array.from({ length: 24 }, (_, h) => h)
|
||||
.filter((h) => h % (cellW >= 22 ? 1 : cellW >= 12 ? 3 : 6) === 0)
|
||||
.map((h) => (
|
||||
<text key={h} x={left + h * cellW + cellW / 2} y={12} textAnchor="middle">
|
||||
{h}
|
||||
</text>
|
||||
@@ -245,7 +247,9 @@ export function Heatmap({
|
||||
)}
|
||||
</svg>
|
||||
<div className="legend" style={{ marginTop: 6 }}>
|
||||
<span>{format(0)}</span>
|
||||
<i style={{ background: SEQ[0], width: 18 }} />
|
||||
<span>{t("stats.heatEmpty")}</span>
|
||||
<span style={{ marginLeft: 12 }}>{format(0)}</span>
|
||||
{SEQ.slice(1).map((c, i) => (
|
||||
<i key={i} style={{ background: c, width: 18 }} />
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
import { useModels } from "../hooks";
|
||||
@@ -12,7 +13,7 @@ import { ShareDialog } from "./ShareDialog";
|
||||
import { QueueList } from "./QueueList";
|
||||
import { BudgetChip } from "./Budget";
|
||||
import { PermissionCard, QuestionCard } from "./Interaction";
|
||||
import { ImageRefs, Markdown } from "./Markdown";
|
||||
import { ImageRefs, Markdown, projectFileUrl } from "./Markdown";
|
||||
import { UsageOverlay } from "./UsageOverlay";
|
||||
import { RewindDialog } from "./RewindDialog";
|
||||
import { ShellsChip } from "./ShellsPanel";
|
||||
@@ -238,7 +239,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</div>
|
||||
{ctxShare !== undefined && (
|
||||
<span className={`chip hide-mobile ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
|
||||
{t("chat.context")} {fmtPercent(ctxShare)}
|
||||
{t("chat.context")} {fmtK(ctxTokens)}/{fmtK(modelInfo!.context!)} · {fmtPercent(ctxShare)}
|
||||
</span>
|
||||
)}
|
||||
{stats && (
|
||||
@@ -325,11 +326,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{texts.map((p) => p.text).join("\n\n")}
|
||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||
{imgs.length > 0 && (
|
||||
<div>
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
|
||||
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -399,3 +402,41 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Attached-file lines ("- /path (12.3 KB)") of a user message become file chips. */
|
||||
const ATTACHED_RE = /^- (\/\S+) \(([\d.]+ (?:B|KB|MB))\)$/;
|
||||
|
||||
function UserText({ text }: { text: string }) {
|
||||
const lines = text.split("\n");
|
||||
const files: { path: string; size: string }[] = [];
|
||||
const rest: string[] = [];
|
||||
for (const l of lines) {
|
||||
const m = l.match(ATTACHED_RE);
|
||||
if (m) files.push({ path: m[1], size: m[2] });
|
||||
else rest.push(l);
|
||||
}
|
||||
// Drop the heading line ("Attached files …:") right before the list.
|
||||
while (files.length && rest.length && (rest[rest.length - 1].trim() === "" || rest[rest.length - 1].trim().endsWith(":"))) {
|
||||
const last = rest.pop()!;
|
||||
if (last.trim().endsWith(":")) break;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{rest.join("\n").trimEnd()}
|
||||
{files.length > 0 && (
|
||||
<div className="user-files">
|
||||
{files.map((f) => (
|
||||
<FileChip key={f.path} url={projectFileUrl(f.path)} name={f.path} sizeText={f.size} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Token counts like "221k" / "1.2M" (same in every language). */
|
||||
function fmtK(n: number) {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, "")}M`;
|
||||
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
||||
return String(n);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAgents, useFetch } from "../hooks";
|
||||
import { useT, type Key } from "../i18n";
|
||||
@@ -17,11 +18,6 @@ const BUILTIN: { name: string; key: Key }[] = [
|
||||
|
||||
const MAX_SIDE = 2000;
|
||||
|
||||
function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
|
||||
async function readImage(file: File): Promise<ImageData> {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
/** Human-readable file size. */
|
||||
export function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const MIME_EXT: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
"image/svg+xml": "svg",
|
||||
"application/pdf": "pdf",
|
||||
"model/gltf-binary": "glb",
|
||||
};
|
||||
|
||||
/** File type label (extension) from a name or MIME type. */
|
||||
export function fileType(name?: string, mime?: string): string {
|
||||
const ext = name?.match(/\.([a-z0-9]{1,6})$/i)?.[1];
|
||||
if (ext) return ext.toUpperCase();
|
||||
if (mime) return (MIME_EXT[mime] ?? mime.split("/").pop() ?? "").toUpperCase();
|
||||
return "";
|
||||
}
|
||||
|
||||
export function basename(p: string) {
|
||||
return p.split(/[\\/]/).filter(Boolean).pop() ?? p;
|
||||
}
|
||||
|
||||
/** Bytes of a base64 payload. */
|
||||
export function base64Size(b64: string) {
|
||||
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
||||
return Math.floor((b64.length * 3) / 4) - pad;
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
size?: number;
|
||||
mime?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, Promise<Meta>>();
|
||||
|
||||
/** Size/type/name of a file URL in the app (project files via stat, others via HEAD). */
|
||||
function loadMeta(url: string): Promise<Meta> {
|
||||
let p = cache.get(url);
|
||||
if (!p) {
|
||||
const project = url.match(/^\/api\/files\/raw\?path=([^&]+)/);
|
||||
p = project
|
||||
? fetch(`/api/files/stat?path=${project[1]}`, { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : {}))
|
||||
.then((j: any) => ({ size: j.size, mime: j.mime }))
|
||||
: fetch(url, { method: "HEAD", credentials: "same-origin" }).then((r) => {
|
||||
if (!r.ok) return {};
|
||||
const len = r.headers.get("content-length");
|
||||
const name = r.headers.get("content-disposition")?.match(/filename="?([^";]+)"?/)?.[1];
|
||||
return { size: len ? Number(len) : undefined, mime: r.headers.get("content-type")?.split(";")[0], name };
|
||||
});
|
||||
p = p.catch(() => ({}));
|
||||
cache.set(url, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function useFileMeta(url: string | undefined, known?: Meta): Meta {
|
||||
const [meta, setMeta] = useState<Meta>(known ?? {});
|
||||
useEffect(() => {
|
||||
if (!url || url.startsWith("data:") || known?.size !== undefined) return;
|
||||
let alive = true;
|
||||
// Bust the cache key per version param so rewritten files show the new size.
|
||||
loadMeta(url).then((m) => alive && setMeta((old) => ({ ...m, ...old, size: m.size ?? old.size })));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [url, known?.size]);
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file shown in the chat: preview with type (top left) and size (top right)
|
||||
* badges, file name below.
|
||||
*/
|
||||
export function FileFigure({
|
||||
url,
|
||||
name,
|
||||
size,
|
||||
mime,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
url?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
mime?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const meta = useFileMeta(url, size !== undefined ? { size, mime, name } : undefined);
|
||||
const shownName = name ?? meta.name;
|
||||
const type = fileType(shownName, mime ?? meta.mime);
|
||||
const sz = size ?? meta.size;
|
||||
return (
|
||||
<figure className={`file-figure ${className ?? ""}`}>
|
||||
<div className="file-preview">
|
||||
{url ? (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{type && <span className="file-badge left">{type}</span>}
|
||||
{sz !== undefined && <span className="file-badge right">{formatSize(sz)}</span>}
|
||||
</div>
|
||||
{shownName && (
|
||||
<figcaption title={shownName}>
|
||||
{url ? (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
{basename(shownName)}
|
||||
</a>
|
||||
) : (
|
||||
basename(shownName)
|
||||
)}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** A non-previewable file: name with type and size as text. */
|
||||
export function FileChip({ url, name, size, sizeText }: { url?: string; name: string; size?: number; sizeText?: string }) {
|
||||
const meta = useFileMeta(url, size !== undefined ? { size } : undefined);
|
||||
const sz = size ?? meta.size;
|
||||
const type = fileType(name, meta.mime);
|
||||
return (
|
||||
<a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}>
|
||||
<span className="file-chip-icon">📎</span>
|
||||
<span className="file-chip-name">{basename(name)}</span>
|
||||
{type && <span className="file-badge">{type}</span>}
|
||||
{(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { memo, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { FileChip, FileFigure } from "./FileFigure";
|
||||
|
||||
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
|
||||
|
||||
@@ -31,7 +32,9 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
|
||||
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`;
|
||||
return (
|
||||
<div className="svg-block">
|
||||
<img src={url} alt="SVG" />
|
||||
<FileFigure name="inline.svg" size={new Blob([code]).size} mime="image/svg+xml">
|
||||
<img src={url} alt="SVG" />
|
||||
</FileFigure>
|
||||
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
|
||||
{showCode ? "▾ SVG" : "▸ SVG"}
|
||||
</button>
|
||||
@@ -53,13 +56,14 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
|
||||
if (!url) return <span>{alt}</span>;
|
||||
// Non-image files (3D models, documents) become links.
|
||||
if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
📎 {alt || src}
|
||||
</a>
|
||||
);
|
||||
return <FileChip url={url} name={src} />;
|
||||
}
|
||||
return <img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />;
|
||||
const name = typeof src === "string" && !src.startsWith("data:") ? decodeURIComponent(src.split("?")[0]) : alt || undefined;
|
||||
return (
|
||||
<FileFigure url={url} name={name} mime={typeof src === "string" ? src.match(/^data:([^;,]+)/)?.[1] : undefined}>
|
||||
<img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />
|
||||
</FileFigure>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => {
|
||||
const code = textOf(children);
|
||||
@@ -105,9 +109,9 @@ export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string })
|
||||
{paths.map((p) => {
|
||||
const url = projectFileUrl(p, baseDir)!;
|
||||
return (
|
||||
<a key={p} href={url} target="_blank" rel="noreferrer" title={p}>
|
||||
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.parentElement as HTMLElement).style.display = "none")} />
|
||||
</a>
|
||||
<FileFigure key={p} url={url} name={p} className="thumb">
|
||||
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.closest("figure") as HTMLElement).style.display = "none")} />
|
||||
</FileFigure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -818,6 +818,19 @@ function Raw({ reload }: { reload: () => void }) {
|
||||
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
|
||||
const USER_TABS = ["general", "account"] as const;
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
general: "⚙️",
|
||||
providers: "🧠",
|
||||
agents: "🤖",
|
||||
mcp: "🔌",
|
||||
permissions: "🛡️",
|
||||
users: "👥",
|
||||
folders: "🗂️",
|
||||
tools: "🧰",
|
||||
audit: "📜",
|
||||
account: "👤",
|
||||
raw: "📝",
|
||||
};
|
||||
|
||||
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
||||
const t = useT();
|
||||
@@ -841,6 +854,9 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
||||
<div className="tabs" role="tablist">
|
||||
{TABS.map((x) => (
|
||||
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
|
||||
<span className="nav-icon" aria-hidden>
|
||||
{TAB_ICONS[x]}
|
||||
</span>
|
||||
{t(`settings.tab.${x}` as Key)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -91,20 +91,25 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
</span>
|
||||
<ThemeModeButton />
|
||||
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
|
||||
<span className="nav-icon" aria-hidden>🚪</span>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-foot">
|
||||
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
|
||||
<span className="nav-icon" aria-hidden>📁</span>
|
||||
{t("nav.files")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
|
||||
<span className="nav-icon" aria-hidden>🖼️</span>
|
||||
{t("nav.images")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||||
<span className="nav-icon" aria-hidden>📊</span>
|
||||
{t("nav.stats")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
|
||||
<span className="nav-icon" aria-hidden>🔧</span>
|
||||
{t("nav.settings")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { base64Size, FileFigure } from "./FileFigure";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -91,9 +92,9 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
<span className="tool-title">{call.input.path}</span>
|
||||
<span className="status ok">✓</span>
|
||||
</div>
|
||||
<a href={writtenImage} target="_blank" rel="noreferrer">
|
||||
<FileFigure url={writtenImage} name={call.input.path}>
|
||||
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
|
||||
</a>
|
||||
</FileFigure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,13 +144,17 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string) => (
|
||||
<a key={id} href={`/api/images/${id}/file`} target="_blank" rel="noreferrer" className="gallery-item">
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img src={`/api/images/${id}/file`} alt="" />
|
||||
</a>
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
result?.images?.map((img, i) => <img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />)
|
||||
result?.images?.map((img, i) => (
|
||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
</FileFigure>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -410,6 +410,7 @@ export const de: Messages = {
|
||||
"stats.prefillSpeed": "Prefill-Geschwindigkeit",
|
||||
"stats.costPerDay": "Kosten pro Tag nach Modell",
|
||||
"stats.tokensPerDay": "Tokens pro Tag",
|
||||
"stats.heatEmpty": "keine Aktivität",
|
||||
"stats.heatmap": "Aktivität nach Wochentag und Uhrzeit",
|
||||
"stats.byModel": "Nach Modell",
|
||||
"stats.byProvider": "Nach Provider",
|
||||
|
||||
@@ -409,6 +409,7 @@ export const en = {
|
||||
"stats.prefillSpeed": "Prefill speed",
|
||||
"stats.costPerDay": "Cost per day by model",
|
||||
"stats.tokensPerDay": "Tokens per day",
|
||||
"stats.heatEmpty": "no activity",
|
||||
"stats.heatmap": "Activity by weekday and hour",
|
||||
"stats.byModel": "By model",
|
||||
"stats.byProvider": "By provider",
|
||||
|
||||
@@ -2090,3 +2090,117 @@ button.danger-solid {
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- files in chat (preview + type/size badges + name) ------------------------- */
|
||||
.file-figure {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 6px 8px 6px 0;
|
||||
max-width: min(100%, 640px);
|
||||
vertical-align: top;
|
||||
}
|
||||
.file-preview {
|
||||
min-width: 128px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.file-preview img {
|
||||
margin: 0 !important;
|
||||
display: block;
|
||||
}
|
||||
.file-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg0) 82%, transparent);
|
||||
color: var(--fg1);
|
||||
border: 1px solid var(--bg3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-preview .file-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.file-preview .file-badge.left {
|
||||
left: 6px;
|
||||
}
|
||||
.file-preview .file-badge.right {
|
||||
right: 6px;
|
||||
}
|
||||
.file-figure figcaption {
|
||||
font-size: 12px;
|
||||
color: var(--fg3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.file-figure figcaption a {
|
||||
color: inherit;
|
||||
}
|
||||
.file-figure.thumb img {
|
||||
height: 140px;
|
||||
max-width: 260px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.msg-user .file-figure img {
|
||||
max-height: 160px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.user-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg3);
|
||||
background: var(--bg1);
|
||||
color: var(--fg1);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.file-chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.file-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-chip-size {
|
||||
color: var(--muted);
|
||||
}
|
||||
.file-chip .file-badge {
|
||||
background: var(--bg2);
|
||||
border: none;
|
||||
}
|
||||
.gallery .file-figure { margin: 0; max-width: none; display: flex; }
|
||||
|
||||
.nav-icon {
|
||||
display: inline-block;
|
||||
width: 1.3em;
|
||||
margin-right: 6px;
|
||||
text-align: center;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.sidebar-foot button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--seq-0: var(--bg1);
|
||||
--seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1));
|
||||
--seq-1: #104281;
|
||||
--seq-2: #184f95;
|
||||
--seq-3: #256abf;
|
||||
@@ -26,7 +26,7 @@
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--seq-0: var(--bg1);
|
||||
--seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1));
|
||||
--seq-1: #cde2fb;
|
||||
--seq-2: #9ec5f4;
|
||||
--seq-3: #6da7ec;
|
||||
|
||||
Reference in new issue
Block a user