studio: agent window, a chat with a model that edits the scene
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
This commit is contained in:
grabowski committed 2026-10-23 19:35:53 +07:00
1 parent c647f39059
commit 1a1b62d475
21 files changed
+1690 -25

No files matched your search

+3 -1
View File
@@ -78,7 +78,9 @@ jobs:
- name: Build dist/web
run: |
python3 tools/build_web.py
# the agent window's default endpoint: the site's relay to B4L's
# inference server (deploy/Caddyfile, handle_path /llm/*)
python3 tools/build_web.py --agent-url /llm/v1
echo "$GITHUB_SHA" > dist/web/version.txt
- name: Set up SSH
Generated
+291 -2
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
@@ -52,7 +58,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -63,7 +69,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -84,6 +90,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -96,6 +108,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
@@ -105,6 +123,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -197,6 +225,15 @@ dependencies = [
"tiny-keccak",
]
[[package]]
name = "crc32fast"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -209,6 +246,23 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -270,6 +324,22 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
@@ -301,6 +371,7 @@ dependencies = [
"isokit-project",
"serde_json",
"tiny_http",
"ureq",
]
[[package]]
@@ -385,6 +456,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -409,6 +490,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -476,6 +563,55 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustls"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -534,6 +670,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
@@ -572,6 +720,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -666,6 +820,47 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -748,12 +943,30 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -763,6 +976,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.4"
@@ -795,6 +1072,18 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
+2
View File
@@ -26,6 +26,8 @@ serde_json = "1"
toml = { version = "1", features = ["preserve_order"] }
rhai = { version = "1.27", default-features = true }
tiny_http = "0.12"
# the Studio's agent window reaches the language model through `isokit studio`
ureq = "3"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
web-time = "1"
+37 -4
View File
@@ -9,6 +9,8 @@ click for details. One Rust binary does everything:
- **Web Studio**: the same Studio running entirely in the browser, with the
engine compiled to WebAssembly. Nothing to install: projects live in the
browser, or in a folder on disk (Chrome / Edge) that agents edit alongside.
- **Agent window**: chat with a language model right in the Studio (by default
B4L's inference server); it edits the scene with the MCP tools while you watch.
- **MCP server**: AI agents (Hermes and others) edit the same scene with the
same operations; their changes show up live in an open Studio.
- **Build**: a self-contained HTML page with every view prerendered, or
@@ -80,6 +82,33 @@ MCP tools also run inside the page: `await isokit.mcp({jsonrpc: "2.0", id: 1,
method: "tools/list"})`, and browsers with WebMCP (`navigator.modelContext`)
get them as page tools.
### Agent window
**✦ Agent** in the Studio's top bar opens a chat with a language model that
edits the scene for you: "plant a row of trees along the road", "make the
workshop one storey taller", "what is on this site?". It works with the 15 MCP
tools below plus two page tools (point at an object, turn the view), streams
its answer and its reasoning, and shows each tool call (click one for the
arguments and the result, click an object id to select it). Every edit lands
on the map at once, and Undo takes it back. Stop ends a turn; the window moves
by its title bar and resizes at its top-left corner.
Any OpenAI-compatible endpoint with tool calling works (vLLM, llama.cpp,
Ollama, LM Studio, hosted APIs). The default is B4L's inference server,
`https://inference.p0.contact/v1`; ⚙ sets endpoint, key, model and whether
the model thinks first.
- `isokit studio` passes the calls through its own server (`/api/agent/*`), so
the key never reaches the page and the endpoint needs no CORS. Settings:
`ISOKIT_LLM_URL`, `ISOKIT_LLM_KEY`, `ISOKIT_LLM_MODEL`, or the window's ⚙,
which saves them in `%APPDATA%\isokit\agent.json` (`~/.config/isokit` elsewhere).
- The web Studio calls the endpoint from the page; the key stays in that
browser. The endpoint must allow the page (CORS, preflight included), or the
site relays it: https://isokit.buildfor.life/ is built with
`--agent-url /llm/v1`, a same-site path that Caddy forwards to B4L's
inference server (`deploy/Caddyfile`). The relay adds no key: each browser
enters its own once.
### Agents (MCP)
While the Studio runs, it also serves MCP over HTTP at `http://127.0.0.1:7878/mcp`.
@@ -127,7 +156,7 @@ storey.
| Command | |
|---|---|
| `isokit new <dir>` | starter project |
| `isokit studio [dir] [--port N] [--no-open]` | GUI editor + `/mcp` + `/view` live preview |
| `isokit studio [dir] [--port N] [--no-open]` | GUI editor + `/mcp` + `/view` live preview + the agent window's link to the model |
| `isokit mcp [dir]` | MCP over stdio |
| `isokit build [dir] [-o file]` | self-contained HTML (all levels × 4 rotations) |
| `isokit render [dir] -o x.svg [--level gf] [--rot 2] [--light]` | one view as SVG |
@@ -144,7 +173,7 @@ crates/isokit-project project folder, document ops, export, check, Workspace (u
crates/isokit-cli the `isokit` binary: Studio HTTP server, MCP stdio, build/render/check
crates/isokit-wasm the engine for the browser (wasm-bindgen): a project in memory behind the Studio API
viewer/ isokit-view.js/.css: pan, zoom, levels, rotation, tooltips (any page can embed it)
studio/ the Studio GUI (served by `isokit studio`, and by the web Studio)
studio/ the Studio GUI (served by `isokit studio`, and by the web Studio); agent.js = agent window
web/ web Studio shell: WASM backend for studio.js, projects, folders, zip, code editor
examples/demo demo project
deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1)
@@ -165,12 +194,16 @@ Plans: [docs/ROADMAP.md](docs/ROADMAP.md).
```bash
cargo test # engine, runtime, ops, demo, MCP
cargo clippy --all-targets -- -D warnings
isokit studio examples/demo --port 7890 --no-open &
uv run --with playwright python tools/check_studio.py <shot-dir> http://127.0.0.1:7890 # GUI + MCP end to end (Edge)
ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DIR=<scratch> \
isokit studio <copy of examples/demo> --port 7890 --no-open & # agent window on tools/mock_llm.py
uv run --with playwright python tools/check_studio.py <shot-dir> http://127.0.0.1:7890 # GUI + MCP + agent window end to end (Edge)
cargo clippy -p isokit-wasm --target wasm32-unknown-unknown -- -D warnings
python tools/build_web.py && uv run --with playwright python tools/check_web.py <shot-dir> # web Studio end to end
```
The agent window checks run on `tools/mock_llm.py`, a scripted stand-in for an
OpenAI-compatible server that the check scripts start on port 7932.
`python tools/build_web.py --skip-cargo` re-copies the front end (studio/,
viewer/, web/) without rebuilding the WASM. Code that runs in the browser must
not use `std::time` (it traps on wasm32-unknown-unknown): use `web_time`.
+1
View File
@@ -13,5 +13,6 @@ path = "src/main.rs"
isokit-project = { workspace = true }
serde_json = { workspace = true }
tiny_http = { workspace = true }
ureq = { workspace = true }
clap = { workspace = true }
anyhow = { workspace = true }
+261
View File
@@ -0,0 +1,261 @@
//! 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());
}
}
+7 -1
View File
@@ -1,13 +1,15 @@
//! `isokit`: the command line.
//!
//! isokit new <dir> start a project
//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp)
//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp,
//! and the agent window's link to a language model)
//! isokit mcp [dir] MCP server over stdio for AI agents
//! isokit build [dir] self-contained HTML page with every view
//! isokit render [dir] -o x.svg one view as SVG
//! isokit check [dir] lint the project
//! isokit prefabs [dir] list the object types
mod agent;
mod serve;
use anyhow::{bail, Context, Result};
@@ -36,6 +38,10 @@ enum Cmd {
title: Option<String>,
},
/// Open the Studio (GUI editor) in the browser
///
/// Its agent window chats with a language model on an OpenAI-compatible
/// endpoint: set it up in the window, or with ISOKIT_LLM_URL,
/// ISOKIT_LLM_KEY and ISOKIT_LLM_MODEL.
Studio {
#[arg(default_value = ".")]
dir: PathBuf,
+29 -1
View File
@@ -1,6 +1,8 @@
//! `isokit studio`: the GUI editor and its JSON API on localhost, plus the
//! MCP endpoint (`POST /mcp`, JSON responses) so agents edit the same scene
//! live. Single-threaded: one request at a time against one Workspace.
//! live. Single-threaded: one request at a time against one Workspace; only
//! the agent window's calls to the language model (`/api/agent/*`, see
//! `agent.rs`) run on threads of their own.
use anyhow::Result;
use isokit_project::workspace::Workspace;
@@ -116,6 +118,21 @@ pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
println!(" project {}", dir.display());
println!(" preview {url}view");
println!(" MCP {url}mcp (or: isokit mcp {})", dir.display());
let ag = crate::agent::load();
println!(
" agent {}{} (key: {})",
ag.url,
if ag.model.is_empty() {
String::new()
} else {
format!(", model {}", ag.model)
},
match ag.key_from {
"env" => "ISOKIT_LLM_KEY",
"file" => "saved settings",
_ => "none yet, set it in the agent window",
}
);
if let Err(e) = &ws.project.runtime {
println!(" script error: {e}");
}
@@ -242,6 +259,17 @@ pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
),
}
}
(Method::Get, "/api/agent/config") => {
send_json(req, crate::agent::public(&crate::agent::load()))
}
(Method::Post, "/api/agent/config") => match crate::agent::save(&bodyv) {
Ok(v) => send_json(req, v),
Err(e) => send_json(req, json!({ "error": e })),
},
(Method::Get, "/api/agent/models") => crate::agent::forward(req, "/models", None),
(Method::Post, "/api/agent/chat") => {
crate::agent::forward(req, "/chat/completions", Some(body))
}
(Method::Post, "/mcp") => {
// a batch (array) or a single message; notifications get 202
let resp = match &bodyv {
+2
View File
@@ -11,6 +11,7 @@ pub const STUDIO_HTML: &str = include_str!("../../../studio/index.html");
pub const STUDIO_JS: &str = include_str!("../../../studio/studio.js");
pub const STUDIO_CSS: &str = include_str!("../../../studio/studio.css");
pub const STUDIO_FAVICON: &str = include_str!("../../../studio/favicon.svg");
pub const STUDIO_AGENT_JS: &str = include_str!("../../../studio/agent.js");
/// Asset by its path in the repository ("viewer/isokit-view.js",
/// "studio/studio.js"), from disk when `ISOKIT_ASSETS` is set.
@@ -28,6 +29,7 @@ pub fn get(path: &str) -> Option<Cow<'static, str>> {
"studio/studio.js" => STUDIO_JS,
"studio/studio.css" => STUDIO_CSS,
"studio/favicon.svg" => STUDIO_FAVICON,
"studio/agent.js" => STUDIO_AGENT_JS,
_ => return None,
};
Some(Cow::Borrowed(s))
+25 -7
View File
@@ -14,12 +14,30 @@ isokit.buildfor.life {
header X-Frame-Options "SAMEORIGIN"
# Testing deployment: keep it out of search engines.
header X-Robots-Tag "noindex, nofollow"
# Revalidate everything: isokit_wasm.js and its .wasm load without a
# version query, and a stale copy at Cloudflare would not match the
# new glue code after a deploy. Unchanged files still come back as 304.
header Cache-Control "no-cache"
root * /var/www/isokit.buildfor.life
encode zstd gzip
file_server
# The agent window's relay to B4L's inference server (the build's
# default endpoint, build_web.py --agent-url /llm/v1). Same site, so the
# page needs no CORS from the endpoint, whose nginx answers the CORS
# preflight with 401. The relay adds no key: the page sends the one its
# user entered, so only key holders get answers. flush_interval -1 passes
# the streamed tokens on as they come.
handle_path /llm/* {
reverse_proxy https://inference.p0.contact {
header_up Host {upstream_hostport}
header_up -Cookie
header_up -Origin
header_up -Referer
flush_interval -1
}
}
handle {
# Revalidate everything: isokit_wasm.js and its .wasm load without a
# version query, and a stale copy at Cloudflare would not match the
# new glue code after a deploy. Unchanged files still come back as 304.
header Cache-Control "no-cache"
root * /var/www/isokit.buildfor.life
encode zstd gzip
file_server
}
}
+13
View File
@@ -36,6 +36,19 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
- [x] `tools/build_web.py` (static output), `tools/check_web.py` (44 checks, Edge)
- [ ] Deploy a hosted copy; render in a Web Worker for very large scenes
## Agent window (done)
- [x] ✦ Agent in the Studio: chat with a model on an OpenAI-compatible endpoint (default: B4L's
inference server) that edits the scene with the MCP tools + page tools (select, view);
streamed answers and reasoning, tool calls with results, clickable object ids, Stop, Undo
- [x] `isokit studio` relays the calls (`/api/agent/*`, key stays on the machine, no CORS);
settings from `ISOKIT_LLM_*` or `agent.json` in the user's config folder
- [x] Web Studio calls the endpoint from the page; isokit.buildfor.life relays `/llm/` to the
inference server (Caddy) so the page needs no CORS from it
- [x] `tools/mock_llm.py` + `tools/agent_checks.py` in both Studio checks
- [ ] Keep the conversation per project across reloads; attach a rendered view (PNG, Phase 4)
for vision models
## Phase 2: richer editing
- [ ] Interior prefabs (desk, table with chairs, shelf, machine, bed, sofa, partition walls) and a
+609
View File
@@ -0,0 +1,609 @@
/* isokit Studio: the agent window. A chat with a language model on an
* OpenAI-compatible endpoint (by default B4L's inference server) that edits
* the open scene with the isokit MCP tools, plus two page tools (point at an
* object, turn the view). Its edits show on the map at once; Undo reverts them.
*
* Under `isokit studio` the model is reached through the Studio server
* (/api/agent/*, crates/isokit-cli/src/agent.rs): the key stays on the machine
* and the endpoint needs no CORS. The web Studio calls the endpoint from the
* page with a key kept in this browser; a build can name a same-site relay as
* the default endpoint (<meta name="isokit-agent" content="/llm/v1">).
*/
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var DEFAULT_URL = "https://inference.p0.contact/v1";
var MAX_STEPS = 32, MAX_RESULT = 24000;
var LS_CFG = "isokit-agent", LS_UI = "isokit-agent-ui";
var SUGGEST = ["What is in this scene?", "Plant a row of trees along the road", "Add a carport with two cars next to the main building", "Make the selected building one storey taller"];
var A = { cfg: null, models: [], modelsErr: null, msgs: [], busy: false, ctl: null, tools: null, instructions: "", rpc: 0, ui: lsGet(LS_UI) || {} };
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
function clip(s, n) { s = String(s); return s.length > n ? s.slice(0, n) + "\n… (" + (s.length - n) + " more characters cut)" : s; }
function st() { return window.__studio; }
function web() { return !!(window.IsoKitBackend && window.IsoKitBackend.web); }
function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || "null"); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* private mode */ } }
function json(r) { return r.json(); }
function saveUi() { lsSet(LS_UI, A.ui); }
// ------------------------------------------------------------ settings
function siteDefault() { var m = document.querySelector('meta[name="isokit-agent"]'); return (m && m.getAttribute("content")) || DEFAULT_URL; }
function loadCfg() {
if (!web()) return fetch("/api/agent/config", { cache: "no-store" }).then(json).then(function (c) { A.cfg = c; return c; });
var c = lsGet(LS_CFG) || {};
A.cfg = { url: c.url || siteDefault(), url_from: c.url ? "browser" : "default", model: c.model || "", think: c.think !== false, key: c.key || "", has_key: !!c.key, key_from: c.key ? "browser" : "" };
return Promise.resolve(A.cfg);
}
/** v: { url, model, think, key } (key undefined: unchanged, "": forget) */
function saveCfg(v) {
if (!web()) {
return fetch("/api/agent/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(v) }).then(json)
.then(function (c) { if (c.error) throw new Error(c.error); A.cfg = c; return c; });
}
var c = lsGet(LS_CFG) || {};
["url", "model", "key", "think"].forEach(function (k) { if (v[k] !== undefined) c[k] = typeof v[k] === "string" ? v[k].trim() : v[k]; });
if (c.url === siteDefault()) delete c.url; // follow the site's default when it moves
lsSet(LS_CFG, c);
return loadCfg();
}
function baseUrl() { return new URL(A.cfg.url.replace(/\/+$/, ""), location.href).href; }
function host() { try { var u = new URL(baseUrl()); return u.host === location.host ? "this site's relay" : u.host; } catch (e) { return A.cfg.url; } }
function model() { return A.cfg.model || A.models[0] || ""; }
// ------------------------------------------------------------ the model's endpoint
function llm(path, body, signal) {
var o = { signal: signal, cache: "no-store", headers: {} };
if (body) { o.method = "POST"; o.body = JSON.stringify(body); o.headers["Content-Type"] = "application/json"; }
if (!web()) return fetch(path === "/models" ? "/api/agent/models" : "/api/agent/chat", o);
if (A.cfg.key) o.headers.Authorization = "Bearer " + A.cfg.key;
return fetch(baseUrl() + path, o).catch(function (e) {
if (e && e.name === "AbortError") throw e;
throw new Error("The browser could not reach " + baseUrl() + " (" + (e && e.message || e) + "). An endpoint on another site has to allow this page (CORS, including the preflight request, which carries no key), or use a relay on this site.");
});
}
function httpError(r) {
return r.text().then(function (t) {
var m = t;
try { var x = JSON.parse(t); m = (x.error && (x.error.message || x.error)) || x.message || x.detail || t; } catch (e) { /* plain text */ }
if (typeof m !== "string") m = JSON.stringify(m);
m = m.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
var hint = r.status === 401 || r.status === 403 ? " Check the API key (⚙)." : r.status === 404 ? " Check the endpoint: most servers want the URL ending in /v1." : "";
return new Error("HTTP " + r.status + (m ? ": " + clip(m, 300) : "") + hint);
});
}
function loadModels() {
return llm("/models").then(function (r) {
if (!r.ok) return httpError(r).then(function (e) { throw e; });
return r.json();
}).then(function (x) {
A.models = (x.data || x.models || []).map(function (m) { return m.id || m.name; }).filter(Boolean);
A.modelsErr = null;
return A.models;
}, function (e) { A.models = []; A.modelsErr = e.message || String(e); throw e; });
}
/** One streamed completion; onDelta(msg) as it grows. */
function complete(messages, signal, onDelta) {
var body = { model: model(), messages: messages, tools: A.tools, stream: true };
if (!A.cfg.think) body.chat_template_kwargs = { enable_thinking: false };
var msg = { content: "", reasoning: "", tool_calls: [] }, finish = null;
function take(ch) {
var d = ch.delta || ch.message || {};
if (d.reasoning || d.reasoning_content) msg.reasoning += d.reasoning || d.reasoning_content;
if (d.content) msg.content += d.content;
(d.tool_calls || []).forEach(function (t, k) {
var i = t.index !== undefined ? t.index : k;
var c = msg.tool_calls[i] || (msg.tool_calls[i] = { id: "", type: "function", "function": { name: "", arguments: "" } });
if (t.id) c.id = t.id;
if (t["function"]) {
if (t["function"].name) c["function"].name += t["function"].name;
if (t["function"].arguments) c["function"].arguments += typeof t["function"].arguments === "string" ? t["function"].arguments : JSON.stringify(t["function"].arguments);
}
});
if (ch.finish_reason) finish = ch.finish_reason;
}
return llm("/chat/completions", body, signal).then(function (r) {
if (!r.ok) return httpError(r).then(function (e) { throw e; });
if ((r.headers.get("content-type") || "").indexOf("text/event-stream") < 0) { // a server that does not stream
return r.json().then(function (x) { if (x.error) throw new Error(x.error.message || JSON.stringify(x.error)); take(x.choices[0]); onDelta(msg); return { msg: msg, finish: finish }; });
}
var rd = r.body.getReader(), dec = new TextDecoder(), buf = "";
return (function pump() {
return rd.read().then(function (x) {
if (x.done) return { msg: msg, finish: finish };
buf += dec.decode(x.value, { stream: true });
var lines = buf.split("\n");
buf = lines.pop();
lines.forEach(function (line) {
line = line.trim();
if (line.indexOf("data:") !== 0) return;
var data = line.slice(5).trim();
if (!data || data === "[DONE]") return;
var j = JSON.parse(data);
if (j.error) throw new Error(j.error.message || JSON.stringify(j.error));
if (j.choices && j.choices[0]) take(j.choices[0]);
});
onDelta(msg);
return pump();
});
})();
});
}
/** Reasoning apart from the answer (servers without a reasoning parser send <think> in the text). */
function split(m) {
var c = m.content || "", r = m.reasoning || "";
var i = c.indexOf("<think>");
if (i >= 0) {
var j = c.indexOf("</think>", i);
r += (r ? "\n" : "") + c.slice(i + 7, j < 0 ? c.length : j);
c = c.slice(0, i) + (j < 0 ? "" : c.slice(j + 8));
}
return { reasoning: r.trim(), content: c.replace(/^\s+/, "") };
}
// ------------------------------------------------------------ tools
function mcp(msg) {
if (web()) {
// reads go straight to the engine; edits through the page, which saves them
return Promise.resolve(msg.method === "tools/call" ? window.isokit.mcp(msg) : JSON.parse(window.isokit.studio.mcp(JSON.stringify(msg)) || "null"));
}
return fetch("/mcp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(msg) })
.then(function (r) { return r.status === 202 ? null : r.json(); });
}
var PAGE = {
select_object: {
description: "Point at an object on the user's screen: select it in the Studio and zoom the view to it. Without id: clear the selection.",
parameters: { type: "object", properties: { id: { type: "string" }, zoom: { type: "boolean", description: "zoom to it (default true)" } }, required: [] },
run: function (a) {
var s = st();
if (!a.id) { s.select(null); return "selection cleared"; }
if (!s.obj(a.id)) throw new Error('no object "' + a.id + '"');
s.select(a.id);
if (a.zoom !== false) s.view.focus(a.id, 3);
return "selected " + a.id + " on the user's screen";
}
},
set_view: {
description: "Turn the user's view (rotation 0-3, quarter turns) or show another level (level ids from project_info; storeys are drawn cut open).",
parameters: { type: "object", properties: { rotation: { type: "integer", minimum: 0, maximum: 3 }, level: { type: "string" } }, required: [] },
run: function (a) {
var s = st(), lv = s.S.meta.levels || [];
if (a.level) {
if (!lv.some(function (l) { return l.id === a.level; })) throw new Error('no level "' + a.level + '" (levels: ' + lv.map(function (l) { return l.id; }).join(", ") + ")");
s.view.setLevel(a.level);
}
if (typeof a.rotation === "number") s.view.setRotation(((Math.round(a.rotation) % 4) + 4) % 4);
return "the user now sees level " + s.view.getLevel() + ", rotation " + s.view.getRotation();
}
}
};
function loadTools() {
if (A.tools) return Promise.resolve(A.tools);
var init = { jsonrpc: "2.0", id: ++A.rpc, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "isokit-agent-window", version: "1" } } };
return Promise.all([mcp(init), mcp({ jsonrpc: "2.0", id: ++A.rpc, method: "tools/list" })]).then(function (rs) {
A.instructions = (rs[0] && rs[0].result && rs[0].result.instructions) || "";
var list = ((rs[1] && rs[1].result && rs[1].result.tools) || []).map(function (t) {
return { type: "function", "function": { name: t.name, description: t.description, parameters: t.inputSchema } };
});
Object.keys(PAGE).forEach(function (k) { list.push({ type: "function", "function": { name: k, description: PAGE[k].description, parameters: PAGE[k].parameters } }); });
A.tools = list;
return list;
});
}
function parseArgs(s) {
if (s && typeof s === "object") return s;
if (!s || !String(s).trim()) return {};
return JSON.parse(s);
}
/** -> { error, text } */
function runTool(name, args) {
var a;
try { a = parseArgs(args); } catch (e) { return Promise.resolve({ error: true, text: "The arguments are not valid JSON (" + e.message + "): send the call again with a JSON object." }); }
if (PAGE[name]) {
try { return Promise.resolve({ error: false, text: PAGE[name].run(a) }); } catch (e) { return Promise.resolve({ error: true, text: e.message || String(e) }); }
}
return mcp({ jsonrpc: "2.0", id: ++A.rpc, method: "tools/call", params: { name: name, arguments: a } }).then(function (r) {
if (!r) return { error: true, text: "no answer from the scene" };
if (r.error) return { error: true, text: r.error.message || JSON.stringify(r.error) };
var res = r.result || {};
return { error: !!res.isError, text: (res.content || []).map(function (c) { return c.text || ""; }).join("\n") };
}, function (e) { return { error: true, text: String(e && e.message || e) }; });
}
/** The Studio redraws as soon as an edit lands (its poll would take up to a second). */
function follow() {
var s = st();
return Promise.resolve(s.api("/api/version")).then(function (r) { if (r.version !== s.S.version) return s.reloadAll(); }).catch(function () { /* the poll catches up */ });
}
// ------------------------------------------------------------ the conversation
function compact(o) {
var c = { id: o.id, type: o.type, x: o.x, y: o.y };
["z", "rot", "name", "tag", "props", "levels", "locked", "hidden"].forEach(function (k) { if (o[k] !== undefined && o[k] !== null && o[k] !== "") c[k] = o[k]; });
return c;
}
function system() {
var s = st(), S = s.S, sel = S.sel && s.obj(S.sel), v = s.view;
return {
role: "system",
content: [
"You are the agent inside the isokit Studio, the page where the user edits an isometric scene. You change the scene with the tools; every edit appears on the user's map at once, and the user can take any edit back with Undo (Ctrl+Z).",
A.instructions,
"How to work: look before you build (list_objects shows what is where with world boxes; list_prefabs with a type gives its props). Place things inside the site, on free ground, and use the boxes to avoid overlaps. Put related edits into one apply_ops call so they undo as one step. After bigger changes, check with render_view or check_project. Use select_object to show the user what you mean. Ask back when a request is unclear instead of guessing at big changes.",
"Answer in the user's language, short and plain, without tables: say what you changed and give object ids in backticks (the user can click them).",
"The Studio right now: project \"" + S.meta.title + "\", " + (S.doc.objects || []).length + " objects; view level \"" + v.getLevel() + "\", rotation " + v.getRotation() + "; snap step " + (s.snapStep() || "off") + ". " +
(sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.")
].filter(Boolean).join("\n\n")
};
}
function send(text) {
text = String(text || "").trim();
if (!text || A.busy) return;
A.msgs.push({ role: "user", content: text });
addMsg("ag-user", esc(text).replace(/\n/g, "<br>"));
busy(true);
var ctl = new AbortController(), step = 0, pending = null;
A.ctl = ctl;
function turn() {
if (step++ >= MAX_STEPS) { note("Stopped after " + MAX_STEPS + " steps. Say \u201ccontinue\u201d to go on."); return null; }
var view = botView();
return complete([system()].concat(A.msgs), ctl.signal, view.update).then(function (res) {
view.done(res.msg);
var m = split(res.msg), calls = res.msg.tool_calls.filter(function (c) { return c && c["function"].name; });
var out = { role: "assistant", content: m.content || "" };
if (calls.length) {
out.tool_calls = calls.map(function (c, i) { return { id: c.id || "call_" + Date.now().toString(36) + "_" + i, type: "function", "function": { name: c["function"].name, arguments: c["function"].arguments || "{}" } }; });
}
A.msgs.push(out);
if (!calls.length) {
if (res.finish === "length") note("The answer hit the length limit and was cut off.");
if (!m.content) note("The model gave no answer.");
return null;
}
pending = out.tool_calls.slice();
return out.tool_calls.reduce(function (p, c) {
return p.then(function () {
if (ctl.signal.aborted) return;
var chip = toolView(c);
return runTool(c["function"].name, c["function"].arguments).then(function (r) {
chip.done(r);
A.msgs.push({ role: "tool", tool_call_id: c.id, content: clip(r.text, MAX_RESULT) });
pending.shift();
});
});
}, Promise.resolve()).then(follow).then(function () { if (!ctl.signal.aborted) return turn(); });
}, function (e) {
view.done(null);
throw e;
});
}
ready().then(turn).catch(function (e) {
if (ctl.signal.aborted || (e && e.name === "AbortError")) return;
error(e && e.message || String(e));
}).then(function () {
// every tool call needs an answer, or the endpoint refuses the next request
(pending || []).forEach(function (c) { A.msgs.push({ role: "tool", tool_call_id: c.id, content: "not run: the turn was stopped" }); });
if (ctl.signal.aborted) { note("Stopped."); follow(); }
busy(false);
A.ctl = null;
});
}
function stop() { if (A.ctl) A.ctl.abort(); }
/** Settings and tools loaded (once; a failed start is tried again on the next message). */
function ready() {
if (!A.ready) {
A.ready = loadCfg().then(function () { who(); return Promise.all([loadTools(), connect()]); });
A.ready.catch(function () { A.ready = null; });
}
return A.ready;
}
function newChat() { stop(); A.msgs = []; renderEmpty(); $("agtext").focus(); }
// ------------------------------------------------------------ chat view
function log() { return $("aglog"); }
function nearEnd() { var l = log(); return l.scrollHeight - l.scrollTop - l.clientHeight < 60; }
function addMsg(cls, html) {
var e = log().querySelector(".ag-empty");
if (e) e.remove();
var d = document.createElement("div");
d.className = cls;
d.innerHTML = html;
log().appendChild(d);
log().scrollTop = log().scrollHeight;
return d;
}
function note(t) { addMsg("ag-note", esc(t)); }
function error(t) {
var d = addMsg("ag-err", esc(t) + ' <button type="button" class="ag-link" data-act="settings">Settings</button> <button type="button" class="ag-link" data-act="retry">Try again</button>');
return d;
}
function renderEmpty() {
log().innerHTML = '<div class="ag-empty"><p>Ask for changes in plain words. The agent edits the scene with the same tools as you, and <b>Undo</b> takes any of its steps back.</p>' +
'<div class="ag-sugs">' + SUGGEST.map(function (s) { return '<button type="button" class="ag-sug">' + esc(s) + "</button>"; }).join("") + "</div></div>";
}
function md(src) {
var html = "", list = null, code = null;
function close() { if (list) { html += "</" + list + ">"; list = null; } }
function inline(s) {
return esc(s).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>").replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>");
}
src.replace(/\r/g, "").split("\n").forEach(function (line) {
if (/^\s*```/.test(line)) {
if (code === null) { close(); code = []; } else { html += "<pre>" + esc(code.join("\n")) + "</pre>"; code = null; }
return;
}
if (code !== null) { code.push(line); return; }
var m = /^\s*(?:([-*•])|(\d+)[.)])\s+(.*)$/.exec(line);
if (m) {
var kind = m[1] ? "ul" : "ol";
if (list !== kind) { close(); html += "<" + kind + ">"; list = kind; }
html += "<li>" + inline(m[3]) + "</li>";
return;
}
close();
if (!line.trim()) return;
var h = /^#{1,6}\s+(.*)$/.exec(line);
html += h ? "<p><b>" + inline(h[1]) + "</b></p>" : "<p>" + inline(line) + "</p>";
});
close();
if (code !== null) html += "<pre>" + esc(code.join("\n")) + "</pre>";
return html;
}
/** `ids` of objects in the scene become links that select them. */
function linkIds(el) {
var s = st();
Array.prototype.forEach.call(el.querySelectorAll("code"), function (c) {
var id = c.textContent.trim();
if (s.obj(id)) { c.classList.add("ag-obj"); c.setAttribute("data-obj", id); c.title = "Select and zoom to it"; }
});
}
function botView() {
var d = addMsg("ag-msg ag-bot ag-live", '<details class="ag-think" hidden><summary>Thinking…</summary><div></div></details><div class="ag-text"></div>');
var think = d.querySelector(".ag-think"), tt = think.querySelector("div"), tx = d.querySelector(".ag-text");
var t0 = Date.now(), t1 = 0, last = null, raf = 0;
function paint() {
raf = 0;
if (!last) return;
var stick = nearEnd(), m = split(last);
if (m.reasoning) { think.hidden = false; tt.textContent = m.reasoning; }
tx.innerHTML = md(m.content);
if (stick) log().scrollTop = log().scrollHeight;
}
return {
update: function (m) {
last = m;
if (!t1 && (split(m).content || m.tool_calls.length)) t1 = Date.now();
if (!raf) raf = requestAnimationFrame(paint);
},
done: function (m) { // m null: broken off, keep what came
if (raf) cancelAnimationFrame(raf);
if (m) last = m;
paint();
d.classList.remove("ag-live");
var p = last ? split(last) : { reasoning: "", content: "" };
if (p.reasoning) think.querySelector("summary").textContent = "Thought for " + Math.max(1, Math.round(((t1 || Date.now()) - t0) / 1000)) + " s";
if (!p.content && !p.reasoning) d.remove();
else linkIds(tx);
}
};
}
function num(v) { return typeof v === "number" ? +v.toFixed(2) : v; }
function summary(n, a) {
var ops = a.ops || [];
switch (n) {
case "add_object": return (a.type || "?") + (a.x !== undefined ? " at " + num(a.x) + ", " + num(a.y) : "");
case "update_object": return (a.id || "?") + ": " + Object.keys(a).filter(function (k) { return k !== "id"; }).map(function (k) { return k === "props" ? Object.keys(a.props || {}).join(", ") : k; }).join(", ");
case "remove_object": case "get_object": case "duplicate_object": case "select_object": return a.id || "";
case "apply_ops": return ops.length + " edit" + (ops.length === 1 ? "" : "s") + ": " + ops.slice(0, 4).map(function (o) { return (o.op || "?") + " " + (o.type || o.id || ""); }).join(", ") + (ops.length > 4 ? ", …" : "");
case "list_prefabs": return a.type || a.category || "";
case "list_objects": return a.type || "";
case "set_params": return Object.keys(a.values || {}).join(", ");
case "render_view": case "set_view": return [a.level, a.rotation !== undefined ? "rotation " + a.rotation : "", a.format].filter(Boolean).join(", ");
default: return "";
}
}
function touched(n, a, res) {
if (res.error) return null;
try {
if (n === "add_object" || n === "duplicate_object") return JSON.parse(res.text).id || null;
if (n === "apply_ops") { var ids = (JSON.parse(res.text).ids || []).filter(Boolean); return ids[ids.length - 1] || null; }
} catch (e) { return null; }
if (n === "update_object" || n === "select_object" || n === "get_object") return a.id || null;
return null;
}
function toolView(c) {
var n = c["function"].name, a = {};
try { a = parseArgs(c["function"].arguments); } catch (e) { /* shown raw */ }
var d = addMsg("ag-tool", '<details><summary><b>' + esc(n) + "</b> <span>" + esc(summary(n, a)) + '</span><em>…</em></summary><pre class="ag-args"></pre><pre class="ag-res"></pre></details>');
d.querySelector(".ag-args").textContent = typeof c["function"].arguments === "string" && !Object.keys(a).length ? c["function"].arguments : JSON.stringify(a, null, 1);
return {
done: function (r) {
var em = d.querySelector("em"), id = touched(n, a, r);
d.classList.add(r.error ? "ag-bad" : "ag-ok");
em.textContent = r.error ? "✗" : "✓";
em.title = r.error ? clip(r.text, 300) : "done";
d.querySelector(".ag-res").textContent = clip(r.text, 6000);
if (id) { // the Studio may not have reloaded the new object yet; a click checks
var link = '<code class="ag-obj" data-obj="' + esc(id) + '" title="Select and zoom to it">' + esc(id) + "</code>", sp = d.querySelector("summary span");
// "id: x, y" -> the id itself becomes the link; else it follows the summary
if (sp.textContent.indexOf(id) === 0) { sp.textContent = sp.textContent.slice(id.length); sp.insertAdjacentHTML("beforebegin", link); }
else em.insertAdjacentHTML("beforebegin", link);
}
}
};
}
function busy(on) {
A.busy = on;
var b = $("agsend");
b.textContent = on ? "Stop" : "Send";
b.classList.toggle("ag-stop", on);
b.title = on ? "Stop the agent" : "Send (Enter)";
$("agwin").classList.toggle("ag-busy", on);
}
// ------------------------------------------------------------ settings form
function who() {
var b = $("agwho");
if (!A.cfg) { b.innerHTML = "…"; return; }
var ok = !A.modelsErr && A.models.length;
b.innerHTML = '<i class="ag-dot ' + (ok ? "ag-on" : A.modelsErr ? "ag-off" : "") + '"></i>' + esc((model() || "no model") + " · " + host());
b.title = A.modelsErr ? "Not connected: " + A.modelsErr : "Model and endpoint (⚙ to change)";
}
function fillSettings() {
var c = A.cfg, f = $("agsetf");
$("agurl").value = c.url;
$("agurl").disabled = c.url_from === "env";
$("agurl").title = c.url_from === "env" ? "Set by ISOKIT_LLM_URL" : "OpenAI-compatible base URL, usually ending in /v1";
$("agkey").value = "";
$("agkey").disabled = c.key_from === "env";
$("agkey").placeholder = c.key_from === "env" ? "set by ISOKIT_LLM_KEY" : c.has_key ? "saved (type to replace)" : "none (most hosted endpoints need one)";
$("agforget").hidden = !c.has_key || c.key_from === "env";
var sel = $("agmodel"), names = A.models.slice();
if (c.model && names.indexOf(c.model) < 0) names.unshift(c.model);
sel.innerHTML = '<option value="">' + esc(A.models.length ? "first offered (" + A.models[0] + ")" : "first offered") + "</option>" +
names.map(function (m) { return "<option" + (m === c.model ? " selected" : "") + ">" + esc(m) + "</option>"; }).join("");
$("agthink").checked = c.think !== false;
$("agsetnote").innerHTML = web()
? "Kept in this browser only." + (siteDefault() !== DEFAULT_URL ? " Default endpoint: " + esc(siteDefault()) + " (this site's relay to B4L's inference server)." : "")
: "Kept by the Studio server" + (c.file ? " in <code>" + esc(c.file) + "</code>" : "") + "; the key never reaches this page.";
setMsg(A.modelsErr ? A.modelsErr : A.models.length ? "Connected: " + A.models.length + " model" + (A.models.length === 1 ? "" : "s") : "", !!A.modelsErr);
f.hidden = false;
$("agset").setAttribute("aria-pressed", "true");
}
function setMsg(t, bad) { var m = $("agsetmsg"); m.textContent = t; m.classList.toggle("ag-badtext", !!bad); m.title = t; }
function hideSettings() { $("agsetf").hidden = true; $("agset").setAttribute("aria-pressed", "false"); }
function saveSettings(e) {
e.preventDefault();
var v = { model: $("agmodel").value, think: $("agthink").checked };
if (!$("agurl").disabled) v.url = $("agurl").value.trim();
if ($("agkey").value.trim()) v.key = $("agkey").value.trim();
setMsg("Saving…");
saveCfg(v).then(connect).then(function () { fillSettings(); if (!A.modelsErr) setTimeout(hideSettings, 700); }, function (err) { setMsg(String(err && err.message || err), true); });
}
function connect() {
return loadModels().catch(function () { /* shown in the form and the dot */ }).then(who);
}
// ------------------------------------------------------------ window
function geom() { var g = A.ui.geom || {}; return { r: g.r === undefined ? 12 : g.r, b: g.b === undefined ? 12 : g.b, w: g.w || 390, h: g.h || 480 }; }
function place() {
var win = $("agwin");
if (win.hidden) return;
var c = win.parentNode, foot = c.querySelector(".st-status"), fh = foot ? foot.offsetHeight : 0;
var cw = c.clientWidth, ch = c.clientHeight - fh, g = geom();
var w = Math.max(280, Math.min(g.w, cw - 8)), h = Math.max(220, Math.min(g.h, ch - 8));
var r = Math.max(4, Math.min(g.r, cw - w - 4)), b = Math.max(4, Math.min(g.b, ch - h - 4));
win.style.width = w + "px"; win.style.height = h + "px";
win.style.right = r + "px"; win.style.bottom = (b + fh) + "px";
}
function dragger(el, fn) {
el.addEventListener("pointerdown", function (e) {
if (e.button !== 0 || e.target.closest("button, input, select, a, code")) return;
e.preventDefault();
var g0 = geom(), x0 = e.clientX, y0 = e.clientY;
el.setPointerCapture(e.pointerId);
function mv(ev) { A.ui.geom = fn(g0, ev.clientX - x0, ev.clientY - y0); place(); }
function up() {
el.removeEventListener("pointermove", mv); el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up);
var win = $("agwin"), foot = win.parentNode.querySelector(".st-status");
A.ui.geom = { w: win.offsetWidth, h: win.offsetHeight, r: parseFloat(win.style.right), b: parseFloat(win.style.bottom) - (foot ? foot.offsetHeight : 0) };
saveUi();
}
el.addEventListener("pointermove", mv); el.addEventListener("pointerup", up); el.addEventListener("pointercancel", up);
});
}
function open(on) {
var win = $("agwin");
win.hidden = !on;
$("agent").setAttribute("aria-pressed", String(on));
A.ui.open = on; saveUi();
if (!on) return;
place();
$("agtext").focus();
if (!A.started) {
A.started = true;
ready().then(function () { if (A.modelsErr && !A.msgs.length) fillSettings(); })
.catch(function (e) { error("The agent could not start: " + (e && e.message || e)); });
}
}
function buildUI() {
var btn = document.createElement("button");
btn.type = "button"; btn.id = "agent"; btn.className = "ag-open";
btn.title = "Chat with an AI agent that edits this scene";
btn.setAttribute("aria-pressed", "false");
btn.textContent = "✦ Agent";
$("preview").parentNode.insertBefore(btn, $("preview"));
var win = document.createElement("section");
win.className = "ag-win"; win.id = "agwin"; win.hidden = true;
win.setAttribute("aria-label", "Agent");
win.innerHTML =
'<header class="ag-head" id="aghead" title="Drag to move">' +
'<b class="ag-logo">✦ Agent</b><button type="button" class="ag-who" id="agwho"></button><span class="st-grow"></span>' +
'<button type="button" id="agnew" title="Start a new chat (the scene stays as it is)">New chat</button>' +
'<button type="button" id="agset" title="Endpoint, key and model" aria-pressed="false">⚙</button>' +
'<button type="button" id="agclose" title="Close (the chat stays)">✕</button></header>' +
'<form class="ag-set" id="agsetf" hidden autocomplete="off">' +
'<label for="agurl">Endpoint</label><input id="agurl" spellcheck="false" placeholder="https://…/v1">' +
'<label for="agkey">API key</label><div class="ag-row"><input id="agkey" type="password" autocomplete="new-password"><button type="button" id="agforget" title="Forget the saved key">Forget</button></div>' +
'<label for="agmodel">Model</label><div class="ag-row"><select id="agmodel"></select><button type="button" id="agrefresh" title="Ask the endpoint for its models">↻</button></div>' +
'<span></span><label class="ag-check"><input type="checkbox" id="agthink"> Think before answering <small>(slower, plans better)</small></label>' +
'<p class="ag-fnote" id="agsetnote"></p>' +
'<div class="ag-row ag-end"><span class="ag-smsg" id="agsetmsg"></span><button type="submit">Save</button></div></form>' +
'<div class="ag-log" id="aglog" aria-live="polite"></div>' +
'<form class="ag-in" id="agin"><textarea id="agtext" rows="2" placeholder="Tell the agent what to change… (Enter sends, Shift+Enter for a new line)"></textarea>' +
'<button type="submit" id="agsend" title="Send (Enter)">Send</button></form>' +
'<span class="ag-grip" id="aggrip" title="Drag to resize"></span>';
document.querySelector(".st-centre").appendChild(win);
renderEmpty();
btn.addEventListener("click", function () { open(win.hidden); });
$("agclose").addEventListener("click", function () { open(false); });
$("agnew").addEventListener("click", newChat);
$("agset").addEventListener("click", function () { if ($("agsetf").hidden) fillSettings(); else hideSettings(); });
$("agwho").addEventListener("click", function () { if ($("agsetf").hidden) fillSettings(); else hideSettings(); });
$("agsetf").addEventListener("submit", saveSettings);
$("agrefresh").addEventListener("click", function () { setMsg("Asking…"); connect().then(fillSettings); });
$("agforget").addEventListener("click", function () { saveCfg({ key: "" }).then(connect).then(fillSettings); });
$("agin").addEventListener("submit", function (e) {
e.preventDefault();
if (A.busy) { stop(); return; }
var t = $("agtext");
if (!t.value.trim()) return;
send(t.value); t.value = "";
});
$("agtext").addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (!A.busy) $("agin").requestSubmit(); }
if (e.key === "Escape") { if (A.busy) stop(); else e.target.blur(); }
});
log().addEventListener("click", function (e) {
var s = e.target.closest(".ag-sug");
if (s) { send(s.textContent); return; }
var o = e.target.closest(".ag-obj");
if (o) {
e.preventDefault();
var id = o.getAttribute("data-obj");
if (st().obj(id)) { st().select(id); st().view.focus(id, 3); } else st().toast("\u201c" + id + "\u201d is no longer in the scene");
return;
}
var b = e.target.closest("button[data-act]");
if (b && b.getAttribute("data-act") === "settings") fillSettings();
if (b && b.getAttribute("data-act") === "retry" && !A.busy) {
// drop the failed turn's leftovers and ask again
while (A.msgs.length && A.msgs[A.msgs.length - 1].role !== "user") A.msgs.pop();
var last = A.msgs.pop();
b.closest(".ag-err").remove();
if (last) send(last.content);
}
});
dragger($("aghead"), function (g, dx, dy) { return { w: g.w, h: g.h, r: g.r - dx, b: g.b - dy }; });
dragger($("aggrip"), function (g, dx, dy) { return { w: g.w - dx, h: g.h - dy, r: g.r, b: g.b }; });
window.addEventListener("resize", place);
if (A.ui.open) open(true);
window.__agent = { A: A, send: send, stop: stop, open: open, newChat: newChat };
}
function start() { if (!$("agwin")) buildUI(); }
if (window.__studio && window.__studio.ready) start();
else window.addEventListener("isokit:studio", start, { once: true });
})();
+3 -1
View File
@@ -68,11 +68,13 @@ document.documentElement.setAttribute("data-theme",t);})();
<tr><th>Copy / delete</th><td>Ctrl+D / Delete</td></tr>
<tr><th>Undo / redo</th><td>Ctrl+Z / Ctrl+Y</td></tr>
<tr><th>View</th><td>drag the ground to pan, Ctrl + wheel or pinch to zoom, ↺ ↻ to rotate, levels to look inside</td></tr>
<tr><th>Agents</th><td>connect an AI agent over MCP; its edits appear here live (see the panel when nothing is selected)</td></tr>
<tr><th>Agent</th><td>✦ Agent opens a chat with a language model that edits the scene for you; Undo takes its steps back. Drag the window by its title, resize it at the top-left corner</td></tr>
<tr><th>MCP</th><td>connect an AI agent over MCP; its edits appear here live (see the panel when nothing is selected)</td></tr>
</table>
<form method="dialog"><button>Close</button></form>
</dialog>
<script src="/assets/viewer/isokit-view.js"></script>
<script src="/assets/studio/studio.js"></script>
<script src="/assets/studio/agent.js"></script>
</body>
</html>
+67
View File
@@ -83,6 +83,73 @@ input[type=checkbox] { accent-color: var(--accent); }
.st-dialog th { text-align: left; padding: 3px 12px 3px 0; color: var(--mute); font-weight: 500; vertical-align: top; }
.st-dialog form { text-align: right; margin-top: 10px; }
/* agent window (agent.js): floats over the map, moves by its title, resizes at the top-left corner */
[hidden] { display: none !important; }
.ag-open[aria-pressed=true] { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 18%, var(--panel)); }
.ag-win { position: absolute; right: 12px; bottom: 40px; width: 400px; height: 540px; z-index: 6; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px; box-shadow: 0 8px 28px rgba(0, 0, 0, .35); overflow: hidden; }
.ag-head { display: flex; align-items: center; gap: 6px; padding: 5px 6px 5px 12px; border-bottom: 1px solid var(--stroke); background: var(--panel2); cursor: move; user-select: none; touch-action: none; }
.ag-head button { padding: 2px 8px; }
.ag-logo { white-space: nowrap; }
.ag-logo, .ag-open { color: var(--fg); }
.ag-who { background: none; border: 0; padding: 0 2px !important; color: var(--mute); font-size: 12px; overflow: hidden; text-overflow: ellipsis; min-width: 0; display: flex; align-items: center; gap: 5px; }
.ag-who:hover { color: var(--fg); }
.ag-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--mute); flex: 0 0 auto; }
.ag-dot.ag-on { background: var(--accent); }
.ag-dot.ag-off { background: var(--danger); }
.ag-set { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 5px 8px; align-items: center; padding: 8px 10px; border-bottom: 1px solid var(--stroke); font-size: 12px; }
.ag-set > label { color: var(--mute); }
.ag-set input:not([type=checkbox]), .ag-set select { width: 100%; }
.ag-row { display: flex; gap: 4px; align-items: center; min-width: 0; }
.ag-row > input, .ag-row > select { flex: 1 1 auto; }
.ag-row button { padding: 2px 8px; }
.ag-check { color: var(--fg); display: flex; align-items: center; gap: 5px; }
.ag-check small { color: var(--mute); }
.ag-fnote { grid-column: 1 / -1; margin: 0; color: var(--mute); }
.ag-fnote code { font-size: 11px; word-break: break-all; }
.ag-end { grid-column: 1 / -1; justify-content: flex-end; }
.ag-smsg { flex: 1 1 auto; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ag-badtext { color: var(--danger); white-space: normal; }
.ag-log { flex: 1 1 auto; overflow: auto; padding: 10px 10px 4px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.ag-empty { color: var(--mute); margin: auto 0 0; }
.ag-empty p { margin: 0 0 8px; }
.ag-sugs { display: flex; flex-wrap: wrap; gap: 5px; }
.ag-sug { white-space: normal; text-align: left; font-size: 12px; padding: 4px 9px; border-radius: 14px; }
.ag-user { align-self: flex-end; max-width: 88%; background: color-mix(in srgb, var(--accent) 16%, var(--panel2)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--stroke)); border-radius: 10px 10px 2px 10px; padding: 5px 10px; overflow-wrap: anywhere; }
.ag-bot { overflow-wrap: anywhere; }
.ag-bot p { margin: 0 0 6px; }
.ag-bot p:last-child { margin-bottom: 0; }
.ag-bot ul, .ag-bot ol { margin: 0 0 6px; padding-left: 20px; }
.ag-bot pre, .ag-tool pre { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 4px; padding: 5px 7px; margin: 4px 0; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow: auto; }
.ag-bot code, .ag-tool code { background: var(--panel2); border-radius: 3px; padding: 0 3px; font-size: 12px; }
.ag-live .ag-text:empty::after, .ag-live .ag-text > :last-child::after { content: "\258D"; color: var(--accent); animation: ag-blink 1s steps(2) infinite; }
.ag-live .ag-think:not([hidden]) + .ag-text:empty::after { content: none; }
@keyframes ag-blink { 50% { opacity: 0; } }
.ag-think { color: var(--mute); font-size: 12px; margin-bottom: 4px; }
.ag-think summary { cursor: pointer; }
.ag-live .ag-think summary { animation: ag-pulse 1.4s ease-in-out infinite; }
@keyframes ag-pulse { 50% { opacity: .45; } }
.ag-think div { white-space: pre-wrap; border-left: 2px solid var(--stroke); padding: 2px 0 2px 8px; margin: 4px 0; max-height: 180px; overflow: auto; }
.ag-tool { font-size: 12px; }
.ag-tool summary { cursor: pointer; display: flex; align-items: center; gap: 6px; color: var(--mute); list-style: none; padding: 2px 6px; border: 1px solid var(--stroke); border-radius: 5px; background: var(--panel2); }
.ag-tool summary::-webkit-details-marker { display: none; }
.ag-tool summary::before { content: "\2699"; }
.ag-tool summary b { color: var(--fg); font-weight: 600; font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; }
.ag-tool summary span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ag-tool summary em { font-style: normal; }
.ag-tool.ag-ok summary em { color: var(--accent); }
.ag-tool.ag-bad summary { border-color: color-mix(in srgb, var(--danger) 50%, var(--stroke)); }
.ag-tool.ag-bad summary em { color: var(--danger); }
.ag-obj { cursor: pointer; color: var(--accent); text-decoration: underline dotted; }
.ag-note { color: var(--mute); font-size: 12px; font-style: italic; }
.ag-err { color: var(--danger); font-size: 12px; border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--stroke)); border-radius: 5px; padding: 5px 8px; overflow-wrap: anywhere; }
.ag-link { background: none; border: 0; padding: 0 2px; color: var(--fg); text-decoration: underline; font-size: 12px; }
.ag-in { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--stroke); align-items: flex-end; }
.ag-in textarea { flex: 1 1 auto; resize: none; min-height: 38px; max-height: 140px; line-height: 1.35; }
.ag-in button { align-self: stretch; min-width: 62px; }
.ag-in .ag-stop { border-color: var(--danger); color: var(--danger); }
.ag-grip { position: absolute; left: 0; top: 0; width: 14px; height: 14px; cursor: nwse-resize; touch-action: none; background: linear-gradient(135deg, var(--mute) 0 2px, transparent 2px 4px, var(--mute) 4px 5px, transparent 5px); opacity: .5; border-top-left-radius: 8px; }
.ag-grip:hover { opacity: 1; }
@media (max-width: 900px) {
.st-main { grid-template-columns: 200px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 40vh; }
.st-right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--stroke); }
+3 -2
View File
@@ -125,7 +125,7 @@
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; }
}
});
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast };
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true };
}
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
function remount() {
@@ -291,7 +291,7 @@
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
(B && B.agentBox ? B.agentBox(m) :
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live.</p>' +
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live. ✦&nbsp;Agent (top bar) chats with one right here.</p>' +
"HTTP (while the Studio runs):<code>" + esc(base) + "/mcp</code>" +
"stdio (any time):<code>isokit mcp " + esc(m.dir || ".") + "</code></div>") +
'<p class="st-help">Select an object to edit it, or pick one on the left to add it.</p>';
@@ -380,5 +380,6 @@
showVersion();
renderPalette(); renderLists(); mountView(); renderRight();
poll();
window.dispatchEvent(new Event("isokit:studio")); // agent.js waits for this
});
})();
+160
View File
@@ -0,0 +1,160 @@
"""Agent window checks, shared by check_studio.py (isokit studio) and
check_web.py (web Studio). The page's agent must use the mock model
(tools/mock_llm.py, key test-key); `start_mock()` runs one."""
import json
import subprocess
import sys
import urllib.request
from pathlib import Path
MOCK_PORT = 7932
MOCK = f"http://127.0.0.1:{MOCK_PORT}"
def start_mock():
p = subprocess.Popen([sys.executable, str(Path(__file__).with_name("mock_llm.py")), str(MOCK_PORT)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
p.stdout.readline() # the banner: listening
return p
def seen():
return json.loads(urllib.request.urlopen(MOCK + "/_requests").read())
def chats():
return [r for r in seen() if r.get("path") == "/v1/chat/completions"]
def run(pg, objects, shot, check, can_set_key):
"""objects(): the scene's objects now. can_set_key: the key is set in the
window (web), not by the Studio server's environment."""
pg.locator("#agent").click()
check(pg.locator("#agwin").is_visible(), "✦ Agent opens the agent window")
pg.wait_for_function("() => /mock-1/.test(document.getElementById('agwho').textContent)", timeout=8000)
check(pg.locator("#agwho .ag-on").count() == 1, "the window connects to the model: " + pg.locator("#agwho").inner_text())
check(pg.locator(".ag-sug").count() >= 3, "an empty chat offers suggestions")
shot("agent-empty.jpg")
# a turn with two tool calls, then an answer
n0 = len(objects())
pg.locator("#agtext").fill("Plant a tree at 6, 4 please")
pg.locator("#agtext").press("Enter")
pg.wait_for_selector(".ag-bot:not(.ag-live) .ag-text code.ag-obj", timeout=15000)
objs = objects()
new = [o for o in objs if o.get("name") == "Agent tree"]
check(len(objs) == n0 + 1 and len(new) == 1 and new[0]["x"] == 6 and new[0]["y"] == 4,
"the agent's add_object lands in the scene at (6, 4)")
tid = new[0]["id"] if new else "?"
check(pg.locator(".ag-tool.ag-ok").count() == 2, "both tool calls show as done")
check(pg.locator(f'.ag-tool code.ag-obj[data-obj="{tid}"]').count() == 1, "the add_object row links the new object")
check(pg.locator(f'#objects li[data-id="{tid}"]').count() == 1, "the object list shows the agent's tree at once")
check(pg.locator(f'.iso-svg g[data-obj="{tid}"]').count() >= 1, "the map draws the agent's tree")
check(pg.locator(".ag-think summary").first.inner_text().startswith("Thought for"), "the reasoning folds away: "
+ pg.locator(".ag-think summary").first.inner_text())
txt = pg.locator(".ag-bot .ag-text").last.inner_text()
check("Planted" in txt and tid in txt, "the answer streams in: " + txt.replace("\n", " ")[:70])
check(pg.locator(".ag-bot .ag-text li").count() == 2 and pg.locator(".ag-bot .ag-text b").count() == 1,
"markdown list and bold render")
shot("agent-chat.jpg")
c = chats()
first, second = c[-2]["body"], c[-1]["body"]
sysmsg = first["messages"][0]
names = [t["function"]["name"] for t in first.get("tools", [])]
check(all(r["auth"] == "Bearer test-key" for r in c[-2:]), "the key reaches the model")
check(sysmsg["role"] == "system" and "isokit Studio" in sysmsg["content"] and "Nothing is selected" in sysmsg["content"]
and "project_info" in sysmsg["content"], "the system prompt has the MCP instructions and the Studio state")
check(len(names) == 17 and {"add_object", "apply_ops", "select_object", "set_view"} <= set(names),
f"the model gets 15 MCP tools + 2 page tools ({len(names)})")
check(first.get("stream") is True and first.get("model") == "mock-1", "streamed, first offered model by default")
tool_ids = [m.get("tool_call_id") for m in second["messages"] if m["role"] == "tool"]
check(tool_ids == ["call_a", "call_b"], f"tool results go back with their call ids {tool_ids}")
# the id in the answer selects the object
pg.locator(f'.ag-bot .ag-text code.ag-obj[data-obj="{tid}"]').click()
pg.wait_for_timeout(500)
check(("id " + tid) in pg.locator("#right .st-sub").inner_text(), "clicking the id selects the agent's tree")
# the selection reaches the model in the next turn; a plain answer
pg.locator("#agtext").fill("hello")
pg.locator("#agtext").press("Enter")
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
check(('"id":"%s"' % tid) in chats()[-1]["body"]["messages"][0]["content"], "the system prompt names the selection")
# Undo takes the agent's edit back
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check(not any(o.get("name") == "Agent tree" for o in objects()), "Undo removes the agent's tree")
# Stop a long answer
pg.locator("#agtext").fill("tell me a slow story")
pg.locator("#agtext").press("Enter")
pg.wait_for_timeout(900)
check(pg.locator("#agsend").inner_text() == "Stop", "while the agent works, Send turns into Stop")
pg.locator("#agsend").click()
pg.wait_for_selector(".ag-note:has-text('Stopped')", timeout=5000)
check(pg.locator("#agsend").inner_text() == "Send" and "word1" in pg.locator(".ag-bot .ag-text").last.inner_text(),
"Stop ends the turn and keeps what came")
# settings: pick another model
def settings():
pg.wait_for_timeout(900) # a good save folds the form away after a moment
if pg.locator("#agsetf").is_hidden():
pg.locator("#agset").click()
def save():
pg.locator('#agsetf button[type="submit"]').click()
pg.wait_for_timeout(700)
settings()
check(pg.locator("#agsetf").is_visible(), "⚙ shows the settings")
opts = pg.locator("#agmodel option").all_inner_texts()
check("mock-2" in opts, f"the model list comes from the endpoint {opts}")
pg.locator("#agmodel").select_option("mock-2")
save()
pg.wait_for_function("() => /mock-2/.test(document.getElementById('agwho').textContent)", timeout=5000)
pg.locator("#agtext").fill("hello again")
pg.locator("#agtext").press("Enter")
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
check(chats()[-1]["body"]["model"] == "mock-2", "the chosen model is used")
if can_set_key:
settings()
pg.locator("#agkey").fill("wrong-key")
save()
check("401" in pg.locator("#agsetmsg").inner_text(), "a wrong key shows the endpoint's refusal: " + pg.locator("#agsetmsg").inner_text())
check(pg.locator("#agwho .ag-off").count() == 1, "the status dot turns red")
shot("agent-badkey.jpg")
pg.locator("#agkey").fill("test-key")
save()
check(pg.locator("#agwho .ag-on").count() == 1, "the right key connects again")
else:
settings()
check(pg.locator("#agurl").is_disabled() and "ISOKIT_LLM_KEY" in pg.locator("#agkey").get_attribute("placeholder"),
"endpoint and key set by the environment are shown as such")
shot("agent-settings.jpg")
settings()
pg.locator("#agmodel").select_option("")
save()
check("mock-1" in pg.locator("#agwho").inner_text(), "back to the first offered model")
# move and resize the window
box = pg.locator("#agwin").bounding_box()
hb = pg.locator(".ag-logo").bounding_box()
pg.mouse.move(hb["x"] + 5, hb["y"] + 5)
pg.mouse.down()
pg.mouse.move(hb["x"] - 95, hb["y"] - 35, steps=5)
pg.mouse.up()
box2 = pg.locator("#agwin").bounding_box()
check(abs(box2["x"] - box["x"] + 100) < 3 and abs(box2["y"] - box["y"] + 40) < 3, "the window moves by its title bar")
gb = pg.locator("#aggrip").bounding_box()
pg.mouse.move(gb["x"] + 4, gb["y"] + 4)
pg.mouse.down()
pg.mouse.move(gb["x"] - 56, gb["y"] - 26, steps=5)
pg.mouse.up()
box3 = pg.locator("#agwin").bounding_box()
check(abs(box3["width"] - box2["width"] - 60) < 3 and abs(box3["height"] - box2["height"] - 30) < 3, "the top-left grip resizes it")
pg.locator("#agnew").click()
check(pg.locator(".ag-empty").count() == 1 and pg.locator(".ag-tool").count() == 0, "New chat clears the conversation")
pg.locator("#agclose").click()
check(pg.locator("#agwin").is_hidden() and pg.locator("#agent").get_attribute("aria-pressed") == "false", "✕ closes the window")
+11 -2
View File
@@ -99,6 +99,10 @@ def build_info():
return ""
def html_attr(s):
return s.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")
def replace_once(s, old, new):
n = s.count(old)
assert n == 1, f"studio/index.html: expected {old!r} once, found {n}"
@@ -109,6 +113,7 @@ def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default=str(ROOT / "dist" / "web"))
ap.add_argument("--skip-cargo", action="store_true", help="reuse the last WASM build (front-end changes only)")
ap.add_argument("--agent-url", help="default endpoint of the agent window, e.g. a relay on the same site (/llm/v1)")
a = ap.parse_args()
out = Path(a.out).resolve()
web = out / "assets" / "web"
@@ -134,7 +139,8 @@ def main():
print(" (wasm-opt not found: the .wasm stays unoptimised by binaryen, about 15% larger)")
for src, dst in [("viewer/isokit-view.js", "viewer"), ("viewer/isokit-view.css", "viewer"),
("studio/studio.js", "studio"), ("studio/studio.css", "studio"), ("studio/favicon.svg", "studio"),
("studio/studio.js", "studio"), ("studio/agent.js", "studio"), ("studio/studio.css", "studio"),
("studio/favicon.svg", "studio"),
("web/web.js", "web"), ("web/web.css", "web")]:
(out / "assets" / dst).mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name)
@@ -150,7 +156,8 @@ def main():
build = build_info()
html = replace_once(html, '<title>isokit Studio</title>',
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">'
+ (f'\n<meta name="isokit-build" content="{build}">' if build else ""))
+ (f'\n<meta name="isokit-build" content="{build}">' if build else "")
+ (f'\n<meta name="isokit-agent" content="{html_attr(a.agent_url)}">' if a.agent_url else ""))
html = replace_once(html, 'href="/assets/studio/favicon.svg"', f'href="assets/studio/favicon.svg?{v}"')
html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"')
html = replace_once(html, '<link rel="stylesheet" href="/assets/theme.css">', '<style id="iso-theme"></style>')
@@ -158,6 +165,8 @@ def main():
f'<link rel="stylesheet" href="assets/studio/studio.css?{v}">\n<link rel="stylesheet" href="assets/web/web.css?{v}">')
html = replace_once(html, 'href="/view"', 'href="#"')
html = replace_once(html, '<script src="/assets/viewer/isokit-view.js"></script>', f'<script src="assets/viewer/isokit-view.js?{v}"></script>')
# agent.js waits for studio.js, which web.js starts once the engine is loaded
html = replace_once(html, '<script src="/assets/studio/agent.js"></script>', f'<script src="assets/studio/agent.js?{v}"></script>')
html = replace_once(html, '<script src="/assets/studio/studio.js"></script>',
f'<script type="module" src="assets/web/web.js?{v}"></script>\n'
'<noscript>The isokit Studio needs JavaScript and WebAssembly.</noscript>')
+19 -1
View File
@@ -1,10 +1,18 @@
"""Functional test of the isokit Studio (GUI) and its MCP endpoint, headless Edge."""
"""Functional test of the isokit Studio (GUI), its MCP endpoint and the agent
window, headless Edge. The agent window runs on a mock model this script starts:
ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DIR=<scratch> \
isokit studio <copy of examples/demo> --port 7890 --no-open
uv run --with playwright python tools/check_studio.py <screenshot dir> http://127.0.0.1:7890
"""
import json
import sys
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
import agent_checks
BASE = sys.argv[2] if len(sys.argv) > 2 else "http://127.0.0.1:7878"
OUT = Path(sys.argv[1])
OUT.mkdir(parents=True, exist_ok=True)
@@ -36,6 +44,7 @@ def obj(id_):
return next((o for o in doc()["objects"] if o["id"] == id_), None)
mock = agent_checks.start_mock()
with sync_playwright() as p:
b = p.chromium.launch(channel="msedge", headless=True)
pg = b.new_page(viewport={"width": 1500, "height": 950})
@@ -140,8 +149,17 @@ with sync_playwright() as p:
pg.locator('[data-a="rotr"]').click()
pg.wait_for_timeout(600)
pg.screenshot(path=str(OUT / "studio-gf.jpg"), type="jpeg", quality=80)
# the agent window, on the mock model (the Studio must run with ISOKIT_LLM_URL=<mock>/v1, see the docstring)
cfg = get("/api/agent/config")
if agent_checks.MOCK not in cfg["url"]:
check(False, "agent window checks need the Studio started with ISOKIT_LLM_URL=" + agent_checks.MOCK + "/v1 (found " + cfg["url"] + ")")
else:
check(not cfg.get("key") and cfg["has_key"] and cfg["key_from"] == "env", "/api/agent/config says a key is set, without the key")
pg.locator('.iso-seg button[data-level="site"]').click()
agent_checks.run(pg, lambda: doc()["objects"], lambda n: pg.screenshot(path=str(OUT / n), type="jpeg", quality=80), check, can_set_key=False)
check(not errs, f"no script errors {errs}")
b.close()
mock.terminate()
print("FAILED:" if fails else "ALL PASSED", fails)
sys.exit(1 if fails else 0)
+15 -2
View File
@@ -7,8 +7,9 @@ Serves the build on a local port and checks: the demo loads from the bundled
files, placing / dragging / props / undo, persistence across reloads, the code
editor (script error and repair), new / export / import (.zip, stored and
deflated), page build and preview, MCP in the page and WebMCP, two tabs on one
project, and a folder project (the origin-private file system stands in for a
folder picked with showDirectoryPicker: same handle API) following outside edits.
project, a folder project (the origin-private file system stands in for a
folder picked with showDirectoryPicker: same handle API) following outside
edits, and the agent window on a mock model (tools/mock_llm.py, started here).
"""
import io
import json
@@ -20,6 +21,8 @@ from pathlib import Path
from playwright.sync_api import sync_playwright
import agent_checks
ROOT = Path(__file__).resolve().parent.parent
OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "dist" / "web-shots"
DIST = Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "dist" / "web"
@@ -60,6 +63,7 @@ WEBMCP_STUB = "navigator.modelContext = { provideContext: function (c) { window.
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
mock = agent_checks.start_mock()
time.sleep(1.0)
try:
with sync_playwright() as p:
@@ -186,6 +190,12 @@ try:
return t.execute({id: "agent-lamp", tag: "via WebMCP"}); }""")
check(r["isError"] is False and obj(pg, "agent-lamp")["tag"] == "via WebMCP", "a WebMCP tool call edits the scene")
# ---------------------------------------------------------------- agent window (mock model, key kept in the browser)
pg.evaluate("(u) => localStorage.setItem('isokit-agent', JSON.stringify({url: u, key: 'test-key'}))", agent_checks.MOCK + "/v1")
agent_checks.run(pg, lambda: doc(pg)["objects"], lambda n: shot(pg, "web-" + n), check, can_set_key=True)
stored = pg.evaluate("() => JSON.parse(localStorage.getItem('isokit-agent'))")
check(stored.get("key") == "test-key" and not stored.get("model"), f"settings stay in this browser {stored}")
# ---------------------------------------------------------------- two tabs on one project
pg2 = ctx.new_page()
watch(pg2)
@@ -309,10 +319,13 @@ try:
pg.wait_for_timeout(400)
shot(pg, "web-narrow.jpg")
# the browser logs the 401 of the agent window's wrong-key check
errs = [e for e in errs if "status of 401" not in e]
check(not errs, f"no page errors {errs[:5]}")
b.close()
finally:
srv.terminate()
mock.terminate()
print("\n%d failure(s)" % len(fails))
sys.exit(1 if fails else 0)
+131
View File
@@ -0,0 +1,131 @@
"""A stand-in for an OpenAI-compatible model server, for the agent window tests.
python tools/mock_llm.py [port] (default 7932; base URL http://127.0.0.1:<port>/v1)
Wants the key `test-key`. Answers like a model that uses tools:
- a user message "plant a tree at X, Y" -> reasoning, then two tool calls:
list_objects {type: tree} and add_object {type: tree, x: X, y: Y, name: "Agent tree"}
- tool results -> a short answer naming the new object's id in backticks
- a user message containing "slow" -> a long, slow answer (to test Stop)
- anything else -> a plain answer
Streams (SSE) when asked to. Every page origin may call it (CORS, also the
preflight). GET /_requests lists the requests seen (headers and bodies).
"""
import json
import re
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
KEY = "test-key"
SEEN = []
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "authorization, content-type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
def reply(self, code, obj):
b = json.dumps(obj).encode()
self.send_response(code)
self.cors()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def authorized(self):
if self.headers.get("Authorization") == "Bearer " + KEY:
return True
self.reply(401, {"error": {"message": "invalid api key"}})
return False
def do_OPTIONS(self):
self.send_response(204)
self.cors()
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
if self.path == "/_requests":
return self.reply(200, SEEN)
if self.path == "/v1/models":
if self.authorized():
self.reply(200, {"object": "list", "data": [{"id": "mock-1", "object": "model"}, {"id": "mock-2", "object": "model"}]})
return
self.reply(404, {"error": {"message": "not found"}})
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}")
SEEN.append({"path": self.path, "auth": self.headers.get("Authorization"), "body": body})
if self.path != "/v1/chat/completions":
return self.reply(404, {"error": {"message": "not found"}})
if not self.authorized():
return
msgs = body.get("messages", [])
last = msgs[-1] if msgs else {}
deltas, finish = [], "stop"
if last.get("role") == "user" and "slow" in last.get("content", ""):
deltas = [{"content": "word%d " % i} for i in range(80)]
return self.stream(body, deltas, finish, pause=0.1)
m = re.search(r"tree at (-?[\d.]+),\s*(-?[\d.]+)", last.get("content", "")) if last.get("role") == "user" else None
if m:
x, y = float(m.group(1)), float(m.group(2))
deltas = [{"reasoning": "The user wants a tree. "}, {"reasoning": "I look at the trees first, then add one."},
{"tool_calls": [{"index": 0, "id": "call_a", "type": "function", "function": {"name": "list_objects", "arguments": ""}}]},
{"tool_calls": [{"index": 0, "function": {"arguments": '{"type": "tree"}'}}]},
{"tool_calls": [{"index": 1, "id": "call_b", "type": "function", "function": {"name": "add_object", "arguments": '{"type": "tree", '}}]},
{"tool_calls": [{"index": 1, "function": {"arguments": '"x": %s, "y": %s, "name": "Agent tree"}' % (x, y)}}]}]
finish = "tool_calls"
elif last.get("role") == "tool":
new_id = "?"
for mm in reversed(msgs):
if mm.get("role") == "tool":
try:
new_id = json.loads(mm["content"]).get("id") or new_id
except (ValueError, AttributeError):
pass
if new_id != "?":
break
deltas = [{"content": "Planted "}, {"content": "`%s` for you." % new_id}, {"content": "\n\n- one tree\n- **no** overlaps"}]
else:
deltas = [{"content": "Hello from the mock model."}]
self.stream(body, deltas, finish)
def stream(self, body, deltas, finish, pause=0.02):
if not body.get("stream"):
msg = {"role": "assistant", "content": "".join(d.get("content", "") for d in deltas)}
return self.reply(200, {"choices": [{"index": 0, "message": msg, "finish_reason": finish}]})
self.send_response(200)
self.cors()
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
try:
for d in [{"role": "assistant", "content": ""}] + deltas:
ch = {"object": "chat.completion.chunk", "model": body.get("model") or "mock-1",
"choices": [{"index": 0, "delta": d, "finish_reason": None}]}
self.wfile.write(("data: %s\n\n" % json.dumps(ch)).encode())
self.wfile.flush()
time.sleep(pause)
end = {"object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {}, "finish_reason": finish}]}
self.wfile.write(("data: %s\n\ndata: [DONE]\n\n" % json.dumps(end)).encode())
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
SEEN.append({"aborted": True})
self.close_connection = True
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 7932
print(f"mock model on http://127.0.0.1:{port}/v1", flush=True)
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
+1 -1
View File
@@ -286,7 +286,7 @@ function backend() {
agentBox: function () {
var dir = isFolder() ? "<folder of " + P.rec.name + ">" : "<project folder>";
return '<div class="st-box"><h2>AI agents (MCP)</h2>' +
'<p class="st-help">The 15 isokit MCP tools run inside this page: browsers with WebMCP offer them to their assistant, and scripts call <b>isokit.mcp(message)</b>.</p>' +
'<p class="st-help">The 15 isokit MCP tools run inside this page: ✦&nbsp;Agent (top bar) chats with a model that uses them, browsers with WebMCP offer them to their assistant, and scripts call <b>isokit.mcp(message)</b>.</p>' +
(isFolder() ? "Desktop agents work on the same folder; their edits show up here live:" : "For desktop agents, open a folder (Projects → Open folder) or export the project, then:") +
"<code>isokit mcp " + esc(dir) + "</code></div>";
}