From 1a1b62d475f5a61788b865054f53c9b813eef0b3 Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 23 Oct 2026 19:35:45 +0700 Subject: [PATCH] studio: agent window, a chat with a model that edits the scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✦ 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 --- .gitea/workflows/deploy-web.yml | 4 +- Cargo.lock | 293 ++++++++++++- Cargo.toml | 2 + README.md | 41 +- crates/isokit-cli/Cargo.toml | 1 + crates/isokit-cli/src/agent.rs | 261 ++++++++++++ crates/isokit-cli/src/main.rs | 8 +- crates/isokit-cli/src/serve.rs | 30 +- crates/isokit-project/src/assets.rs | 2 + deploy/Caddyfile | 32 +- docs/ROADMAP.md | 13 + studio/agent.js | 609 ++++++++++++++++++++++++++++ studio/index.html | 4 +- studio/studio.css | 67 +++ studio/studio.js | 5 +- tools/agent_checks.py | 160 ++++++++ tools/build_web.py | 13 +- tools/check_studio.py | 20 +- tools/check_web.py | 17 +- tools/mock_llm.py | 131 ++++++ web/web.js | 2 +- 21 files changed, 1690 insertions(+), 25 deletions(-) create mode 100644 crates/isokit-cli/src/agent.rs create mode 100644 studio/agent.js create mode 100644 tools/agent_checks.py create mode 100644 tools/mock_llm.py diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index 4bbf992..cb30848 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index f7eecfe..1d4a9f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index dc27666..c1ca5a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index a49fd8a..9322ecd 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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 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= \ + isokit studio --port 7890 --no-open & # agent window on tools/mock_llm.py +uv run --with playwright python tools/check_studio.py 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 # 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`. diff --git a/crates/isokit-cli/Cargo.toml b/crates/isokit-cli/Cargo.toml index dd8b895..1116e61 100644 --- a/crates/isokit-cli/Cargo.toml +++ b/crates/isokit-cli/Cargo.toml @@ -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 } diff --git a/crates/isokit-cli/src/agent.rs b/crates/isokit-cli/src/agent.rs new file mode 100644 index 0000000..175e396 --- /dev/null +++ b/crates/isokit-cli/src/agent.rs @@ -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 { + 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 { + std::env::var(k) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn read_file() -> Map { + config_file() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::>(&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 { + 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 `` 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) { + 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::(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) { + 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 = 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::(&b).unwrap()["model"], "qwen"); + let b = with_model(r#"{"model":"other"}"#, "qwen"); + assert_eq!(serde_json::from_str::(&b).unwrap()["model"], "other"); + let b = with_model(r#"{"messages":[]}"#, ""); + assert!(serde_json::from_str::(&b) + .unwrap() + .get("model") + .is_none()); + } +} diff --git a/crates/isokit-cli/src/main.rs b/crates/isokit-cli/src/main.rs index 51d6fce..039e15a 100644 --- a/crates/isokit-cli/src/main.rs +++ b/crates/isokit-cli/src/main.rs @@ -1,13 +1,15 @@ //! `isokit`: the command line. //! //! isokit new 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, }, /// 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, diff --git a/crates/isokit-cli/src/serve.rs b/crates/isokit-cli/src/serve.rs index 408daf1..259028f 100644 --- a/crates/isokit-cli/src/serve.rs +++ b/crates/isokit-cli/src/serve.rs @@ -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 { diff --git a/crates/isokit-project/src/assets.rs b/crates/isokit-project/src/assets.rs index 90a62c8..07b7985 100644 --- a/crates/isokit-project/src/assets.rs +++ b/crates/isokit-project/src/assets.rs @@ -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> { "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)) diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 94e5cde..a349b49 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -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 + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 14586a9..dd9fc36 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/studio/agent.js b/studio/agent.js new file mode 100644 index 0000000..851c5d8 --- /dev/null +++ b/studio/agent.js @@ -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 (). + */ +(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, "&").replace(//g, ">").replace(/"/g, """); } + 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 in the text). */ + function split(m) { + var c = m.content || "", r = m.reasoning || ""; + var i = c.indexOf(""); + if (i >= 0) { + var j = c.indexOf("", 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, "
")); + 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) + ' '); + return d; + } + function renderEmpty() { + log().innerHTML = '

Ask for changes in plain words. The agent edits the scene with the same tools as you, and Undo takes any of its steps back.

' + + '
' + SUGGEST.map(function (s) { return '"; }).join("") + "
"; + } + function md(src) { + var html = "", list = null, code = null; + function close() { if (list) { html += ""; list = null; } } + function inline(s) { + return esc(s).replace(/`([^`]+)`/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1$2"); + } + src.replace(/\r/g, "").split("\n").forEach(function (line) { + if (/^\s*```/.test(line)) { + if (code === null) { close(); code = []; } else { html += "
" + esc(code.join("\n")) + "
"; 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 += "
  • " + inline(m[3]) + "
  • "; + return; + } + close(); + if (!line.trim()) return; + var h = /^#{1,6}\s+(.*)$/.exec(line); + html += h ? "

    " + inline(h[1]) + "

    " : "

    " + inline(line) + "

    "; + }); + close(); + if (code !== null) html += "
    " + esc(code.join("\n")) + "
    "; + 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", '
    '); + 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", '
    ' + esc(n) + " " + esc(summary(n, a)) + '…
    '); + 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 = '' + esc(id) + "", 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 = '' + 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 = '" + + names.map(function (m) { return "" + esc(m) + ""; }).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 " + esc(c.file) + "" : "") + "; 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 = + '
    ' + + '' + + '' + + '' + + '
    ' + + '' + + '
    ' + + '
    ' + + '
    ' + + ''; + 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 }); +})(); diff --git a/studio/index.html b/studio/index.html index 6b898ee..9cf0502 100644 --- a/studio/index.html +++ b/studio/index.html @@ -68,11 +68,13 @@ document.documentElement.setAttribute("data-theme",t);})(); Copy / deleteCtrl+D / Delete Undo / redoCtrl+Z / Ctrl+Y Viewdrag the ground to pan, Ctrl + wheel or pinch to zoom, ↺ ↻ to rotate, levels to look inside - Agentsconnect an AI agent over MCP; its edits appear here live (see the panel when nothing is selected) + Agent✦ 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 + MCPconnect an AI agent over MCP; its edits appear here live (see the panel when nothing is selected)
    + diff --git a/studio/studio.css b/studio/studio.css index fa8c93d..d6b200d 100644 --- a/studio/studio.css +++ b/studio/studio.css @@ -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); } diff --git a/studio/studio.js b/studio/studio.js index a246075..d771546 100644 --- a/studio/studio.js +++ b/studio/studio.js @@ -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 ? "

    Warnings

    " + S.warnings.map(function (w) { return '
    ' + esc(w) + "
    "; }).join("") : "") + (B && B.agentBox ? B.agentBox(m) : '

    Connect an AI agent (MCP)

    ' + - '

    Agents edit this scene with the same operations as the Studio; their changes appear here live.

    ' + + '

    Agents edit this scene with the same operations as the Studio; their changes appear here live. ✦ Agent (top bar) chats with one right here.

    ' + "HTTP (while the Studio runs):" + esc(base) + "/mcp" + "stdio (any time):isokit mcp " + esc(m.dir || ".") + "
    ") + '

    Select an object to edit it, or pick one on the left to add it.

    '; @@ -380,5 +380,6 @@ showVersion(); renderPalette(); renderLists(); mountView(); renderRight(); poll(); + window.dispatchEvent(new Event("isokit:studio")); // agent.js waits for this }); })(); diff --git a/tools/agent_checks.py b/tools/agent_checks.py new file mode 100644 index 0000000..4666196 --- /dev/null +++ b/tools/agent_checks.py @@ -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") diff --git a/tools/build_web.py b/tools/build_web.py index 1055df1..0257848 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -99,6 +99,10 @@ def build_info(): return "" +def html_attr(s): + return s.replace("&", "&").replace('"', """).replace("<", "<") + + 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, 'isokit Studio', 'isokit Studio\n' - + (f'\n' if build else "")) + + (f'\n' if build else "") + + (f'\n' 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, '', '') @@ -158,6 +165,8 @@ def main(): f'\n') html = replace_once(html, 'href="/view"', 'href="#"') html = replace_once(html, '', f'') + # agent.js waits for studio.js, which web.js starts once the engine is loaded + html = replace_once(html, '', f'') html = replace_once(html, '', f'\n' '') diff --git a/tools/check_studio.py b/tools/check_studio.py index 716dab8..a8b691f 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -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= \ + isokit studio --port 7890 --no-open + uv run --with playwright python tools/check_studio.py 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=/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) diff --git a/tools/check_web.py b/tools/check_web.py index f15858b..5fb66c2 100644 --- a/tools/check_web.py +++ b/tools/check_web.py @@ -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) diff --git a/tools/mock_llm.py b/tools/mock_llm.py new file mode 100644 index 0000000..03ff42e --- /dev/null +++ b/tools/mock_llm.py @@ -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:/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() diff --git a/web/web.js b/web/web.js index ca742d5..afdf757 100644 --- a/web/web.js +++ b/web/web.js @@ -286,7 +286,7 @@ function backend() { agentBox: function () { var dir = isFolder() ? "" : ""; return '

    AI agents (MCP)

    ' + - '

    The 15 isokit MCP tools run inside this page: browsers with WebMCP offer them to their assistant, and scripts call isokit.mcp(message).

    ' + + '

    The 15 isokit MCP tools run inside this page: ✦ Agent (top bar) chats with a model that uses them, browsers with WebMCP offer them to their assistant, and scripts call isokit.mcp(message).

    ' + (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:") + "isokit mcp " + esc(dir) + "
    "; }