Deploy web Studio / deploy (push) Successful in 3m3s
✦ Agent in the Studio's top bar opens a floating chat window. The model (any OpenAI-compatible endpoint with tool calling; default B4L's inference server, https://inference.p0.contact/v1) gets the 15 MCP tools plus two page tools (select_object, set_view) and the Studio's state (selection, level, rotation) in its system prompt. Answers and reasoning stream in; tool calls show as rows with arguments and results; object ids link to the object; Stop ends a turn; Undo takes the agent's edits back. The window moves by its title bar and resizes at its top-left corner. - studio/agent.js: shared by both front ends (waits for studio.js's isokit:studio event); MCP over /mcp or isokit.mcp in the web Studio - isokit studio: /api/agent/{config,models,chat} relay the calls on their own threads (ureq, chunked and flushed per read), so the key stays on the machine and the endpoint needs no CORS; settings from ISOKIT_LLM_URL / _KEY / _MODEL or agent.json in the user's config folder - web Studio: calls the endpoint from the page with a key kept in the browser; build_web.py --agent-url names a same-site relay as default; the deploy builds with /llm/v1, which deploy/Caddyfile forwards to the inference server (its nginx refuses the CORS preflight) - tools/mock_llm.py + tools/agent_checks.py: the agent window checks in both check_studio.py and check_web.py
262 lines
9.2 KiB
Rust
262 lines
9.2 KiB
Rust
//! The Studio's agent window talks to a language model through these routes,
|
|
//! so the endpoint key stays on this machine and the browser needs no CORS
|
|
//! permission from the endpoint (any OpenAI-compatible `/v1` API: vLLM,
|
|
//! llama.cpp, Ollama, LM Studio, a hosted one):
|
|
//!
|
|
//! - `GET /api/agent/config` endpoint, model, whether a key is set (never the key)
|
|
//! - `POST /api/agent/config` save `{ url, model, think, key }` (key "" forgets it)
|
|
//! - `GET /api/agent/models` the endpoint's `/models`
|
|
//! - `POST /api/agent/chat` the endpoint's `/chat/completions`, streamed through
|
|
//!
|
|
//! Settings come from `ISOKIT_LLM_URL`, `ISOKIT_LLM_KEY` and `ISOKIT_LLM_MODEL`,
|
|
//! else from `agent.json` in the user's config folder (`%APPDATA%\isokit`,
|
|
//! `~/.config/isokit`; `ISOKIT_CONFIG_DIR` moves it), which the window writes.
|
|
|
|
use serde_json::{json, Map, Value};
|
|
use std::io::{Read, Write};
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use tiny_http::Request;
|
|
|
|
/// B4L's inference server (vLLM, Qwen); any OpenAI-compatible base URL works.
|
|
pub const DEFAULT_URL: &str = "https://inference.p0.contact/v1";
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Settings {
|
|
pub url: String,
|
|
pub key: String,
|
|
pub model: String,
|
|
pub think: bool,
|
|
/// where url / key came from: "env", "file" or "default" / ""
|
|
pub url_from: &'static str,
|
|
pub key_from: &'static str,
|
|
}
|
|
|
|
pub fn config_file() -> Option<PathBuf> {
|
|
if let Some(d) = var("ISOKIT_CONFIG_DIR") {
|
|
return Some(PathBuf::from(d).join("agent.json"));
|
|
}
|
|
let base = if cfg!(windows) {
|
|
var("APPDATA").map(PathBuf::from)
|
|
} else {
|
|
var("XDG_CONFIG_HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| var("HOME").map(|h| PathBuf::from(h).join(".config")))
|
|
};
|
|
base.map(|b| b.join("isokit").join("agent.json"))
|
|
}
|
|
|
|
fn var(k: &str) -> Option<String> {
|
|
std::env::var(k)
|
|
.ok()
|
|
.map(|v| v.trim().to_string())
|
|
.filter(|v| !v.is_empty())
|
|
}
|
|
|
|
fn read_file() -> Map<String, Value> {
|
|
config_file()
|
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
|
.and_then(|s| serde_json::from_str::<Map<String, Value>>(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn load() -> Settings {
|
|
let f = read_file();
|
|
let fs = |k: &str| {
|
|
f.get(k)
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
};
|
|
let (url, url_from) = match (var("ISOKIT_LLM_URL"), fs("url")) {
|
|
(Some(u), _) => (u, "env"),
|
|
(None, Some(u)) => (u, "file"),
|
|
_ => (DEFAULT_URL.to_string(), "default"),
|
|
};
|
|
let (key, key_from) = match (var("ISOKIT_LLM_KEY"), fs("key")) {
|
|
(Some(k), _) => (k, "env"),
|
|
(None, Some(k)) => (k, "file"),
|
|
_ => (String::new(), ""),
|
|
};
|
|
Settings {
|
|
url,
|
|
key,
|
|
model: var("ISOKIT_LLM_MODEL")
|
|
.or_else(|| fs("model"))
|
|
.unwrap_or_default(),
|
|
think: f.get("think").and_then(|v| v.as_bool()).unwrap_or(true),
|
|
url_from,
|
|
key_from,
|
|
}
|
|
}
|
|
|
|
/// What the page may see: everything but the key.
|
|
pub fn public(s: &Settings) -> Value {
|
|
json!({
|
|
"url": s.url, "url_from": s.url_from, "model": s.model, "think": s.think,
|
|
"has_key": !s.key.is_empty(), "key_from": s.key_from,
|
|
"file": config_file().map(|p| p.display().to_string()),
|
|
})
|
|
}
|
|
|
|
/// Merge the window's settings into the config file. A missing field stays
|
|
/// as it is; `key: ""` forgets the key.
|
|
pub fn save(body: &Value) -> Result<Value, String> {
|
|
let path = config_file().ok_or("no config folder (set ISOKIT_CONFIG_DIR)")?;
|
|
let mut f = read_file();
|
|
for k in ["url", "model", "key"] {
|
|
if let Some(v) = body.get(k).and_then(|v| v.as_str()) {
|
|
let v = v.trim();
|
|
if v.is_empty() {
|
|
f.remove(k);
|
|
} else {
|
|
f.insert(k.into(), json!(v));
|
|
}
|
|
}
|
|
}
|
|
if let Some(b) = body.get("think").and_then(|v| v.as_bool()) {
|
|
f.insert("think".into(), json!(b));
|
|
}
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
|
}
|
|
let text = serde_json::to_string_pretty(&Value::Object(f)).unwrap_or_default();
|
|
std::fs::write(&path, text + "\n").map_err(|e| format!("{}: {e}", path.display()))?;
|
|
Ok(public(&load()))
|
|
}
|
|
|
|
/// Forward a request to `<url><path>` on its own thread (an answer can take
|
|
/// minutes; the Studio keeps serving meanwhile) and stream the answer back.
|
|
pub fn forward(req: Request, path: &'static str, body: Option<String>) {
|
|
std::thread::spawn(move || {
|
|
let s = load();
|
|
let url = format!("{}{}", s.url.trim_end_matches('/'), path);
|
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.http_status_as_error(false)
|
|
.timeout_connect(Some(Duration::from_secs(15)))
|
|
.timeout_recv_response(Some(Duration::from_secs(600)))
|
|
.user_agent(concat!("isokit-studio/", env!("CARGO_PKG_VERSION")))
|
|
.build()
|
|
.into();
|
|
let auth = format!("Bearer {}", s.key);
|
|
let res = match body {
|
|
Some(b) => {
|
|
let b = with_model(&b, &s.model);
|
|
let mut r = agent.post(&url).header("Content-Type", "application/json");
|
|
if !s.key.is_empty() {
|
|
r = r.header("Authorization", &auth);
|
|
}
|
|
r.send(b.as_bytes())
|
|
}
|
|
None => {
|
|
let mut r = agent.get(&url);
|
|
if !s.key.is_empty() {
|
|
r = r.header("Authorization", &auth);
|
|
}
|
|
r.call()
|
|
}
|
|
};
|
|
match res {
|
|
Ok(resp) => stream_back(req, resp),
|
|
Err(e) => {
|
|
let msg = format!("{url}: {e}");
|
|
let body = json!({ "error": { "message": msg } }).to_string();
|
|
let r = tiny_http::Response::from_string(body)
|
|
.with_status_code(502)
|
|
.with_header(
|
|
tiny_http::Header::from_bytes("Content-Type", "application/json")
|
|
.expect("valid header"),
|
|
);
|
|
let _ = req.respond(r);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// The configured model when the page left it open.
|
|
fn with_model(body: &str, model: &str) -> String {
|
|
match serde_json::from_str::<Value>(body) {
|
|
Ok(Value::Object(mut m)) => {
|
|
let open = m
|
|
.get("model")
|
|
.and_then(|v| v.as_str())
|
|
.is_none_or(|s| s.is_empty());
|
|
if open && !model.is_empty() {
|
|
m.insert("model".into(), json!(model));
|
|
}
|
|
Value::Object(m).to_string()
|
|
}
|
|
_ => body.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Copy the endpoint's answer to the page as it arrives (chunked, flushed per
|
|
/// read: tiny_http's own chunked writer would hold back 8 kB at a time).
|
|
fn stream_back(req: Request, resp: ureq::http::Response<ureq::Body>) {
|
|
let status = resp.status();
|
|
let ctype = resp
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("application/json")
|
|
.to_string();
|
|
let sse = ctype.starts_with("text/event-stream");
|
|
let mut body = resp.into_body().into_reader();
|
|
let mut w = req.into_writer();
|
|
let head = format!(
|
|
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nCache-Control: no-store\r\nX-Accel-Buffering: no\r\nTransfer-Encoding: chunked\r\n\r\n",
|
|
status.as_u16(),
|
|
status.canonical_reason().unwrap_or(""),
|
|
ctype
|
|
);
|
|
if w.write_all(head.as_bytes())
|
|
.and_then(|_| w.flush())
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
let mut buf = vec![0u8; 16 << 10];
|
|
loop {
|
|
let chunk: Vec<u8> = match body.read(&mut buf) {
|
|
Ok(0) => break,
|
|
Ok(n) => buf[..n].to_vec(),
|
|
// the endpoint broke off: tell a streaming page why
|
|
Err(e) if sse => {
|
|
let ev = json!({ "error": { "message": format!("the endpoint broke off: {e}") } });
|
|
format!("data: {ev}\n\n").into_bytes()
|
|
}
|
|
Err(_) => break,
|
|
};
|
|
let last = sse && chunk.starts_with(b"data: {\"error\"");
|
|
let ok = write!(w, "{:x}\r\n", chunk.len())
|
|
.and_then(|_| w.write_all(&chunk))
|
|
.and_then(|_| w.write_all(b"\r\n"))
|
|
.and_then(|_| w.flush());
|
|
if ok.is_err() {
|
|
return; // the page stopped reading (Stop): dropping the reader ends the request upstream
|
|
}
|
|
if last {
|
|
break;
|
|
}
|
|
}
|
|
let _ = w.write_all(b"0\r\n\r\n").and_then(|_| w.flush());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn model_fills_in_only_when_open() {
|
|
let b = with_model(r#"{"model":"","messages":[]}"#, "qwen");
|
|
assert_eq!(serde_json::from_str::<Value>(&b).unwrap()["model"], "qwen");
|
|
let b = with_model(r#"{"model":"other"}"#, "qwen");
|
|
assert_eq!(serde_json::from_str::<Value>(&b).unwrap()["model"], "other");
|
|
let b = with_model(r#"{"messages":[]}"#, "");
|
|
assert!(serde_json::from_str::<Value>(&b)
|
|
.unwrap()
|
|
.get("model")
|
|
.is_none());
|
|
}
|
|
}
|