studio: agent window, a chat with a model that edits the scene
Deploy web Studio / deploy (push) Successful in 3m3s
Deploy web Studio / deploy (push) Successful in 3m3s
✦ Agent in the Studio's top bar opens a floating chat window. The model (any OpenAI-compatible endpoint with tool calling; default B4L's inference server, https://inference.p0.contact/v1) gets the 15 MCP tools plus two page tools (select_object, set_view) and the Studio's state (selection, level, rotation) in its system prompt. Answers and reasoning stream in; tool calls show as rows with arguments and results; object ids link to the object; Stop ends a turn; Undo takes the agent's edits back. The window moves by its title bar and resizes at its top-left corner. - studio/agent.js: shared by both front ends (waits for studio.js's isokit:studio event); MCP over /mcp or isokit.mcp in the web Studio - isokit studio: /api/agent/{config,models,chat} relay the calls on their own threads (ureq, chunked and flushed per read), so the key stays on the machine and the endpoint needs no CORS; settings from ISOKIT_LLM_URL / _KEY / _MODEL or agent.json in the user's config folder - web Studio: calls the endpoint from the page with a key kept in the browser; build_web.py --agent-url names a same-site relay as default; the deploy builds with /llm/v1, which deploy/Caddyfile forwards to the inference server (its nginx refuses the CORS preflight) - tools/mock_llm.py + tools/agent_checks.py: the agent window checks in both check_studio.py and check_web.py
This commit is contained in:
1 parent
c647f39059
commit
1a1b62d475
21 files changed
+1684
-19
No files matched your search
@@ -78,7 +78,9 @@ jobs:
|
||||
|
||||
- name: Build dist/web
|
||||
run: |
|
||||
python3 tools/build_web.py
|
||||
# the agent window's default endpoint: the site's relay to B4L's
|
||||
# inference server (deploy/Caddyfile, handle_path /llm/*)
|
||||
python3 tools/build_web.py --agent-url /llm/v1
|
||||
echo "$GITHUB_SHA" > dist/web/version.txt
|
||||
|
||||
- name: Set up SSH
|
||||
|
||||
Generated
+291
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,8 @@ click for details. One Rust binary does everything:
|
||||
- **Web Studio**: the same Studio running entirely in the browser, with the
|
||||
engine compiled to WebAssembly. Nothing to install: projects live in the
|
||||
browser, or in a folder on disk (Chrome / Edge) that agents edit alongside.
|
||||
- **Agent window**: chat with a language model right in the Studio (by default
|
||||
B4L's inference server); it edits the scene with the MCP tools while you watch.
|
||||
- **MCP server**: AI agents (Hermes and others) edit the same scene with the
|
||||
same operations; their changes show up live in an open Studio.
|
||||
- **Build**: a self-contained HTML page with every view prerendered, or
|
||||
@@ -80,6 +82,33 @@ MCP tools also run inside the page: `await isokit.mcp({jsonrpc: "2.0", id: 1,
|
||||
method: "tools/list"})`, and browsers with WebMCP (`navigator.modelContext`)
|
||||
get them as page tools.
|
||||
|
||||
### Agent window
|
||||
|
||||
**✦ Agent** in the Studio's top bar opens a chat with a language model that
|
||||
edits the scene for you: "plant a row of trees along the road", "make the
|
||||
workshop one storey taller", "what is on this site?". It works with the 15 MCP
|
||||
tools below plus two page tools (point at an object, turn the view), streams
|
||||
its answer and its reasoning, and shows each tool call (click one for the
|
||||
arguments and the result, click an object id to select it). Every edit lands
|
||||
on the map at once, and Undo takes it back. Stop ends a turn; the window moves
|
||||
by its title bar and resizes at its top-left corner.
|
||||
|
||||
Any OpenAI-compatible endpoint with tool calling works (vLLM, llama.cpp,
|
||||
Ollama, LM Studio, hosted APIs). The default is B4L's inference server,
|
||||
`https://inference.p0.contact/v1`; ⚙ sets endpoint, key, model and whether
|
||||
the model thinks first.
|
||||
|
||||
- `isokit studio` passes the calls through its own server (`/api/agent/*`), so
|
||||
the key never reaches the page and the endpoint needs no CORS. Settings:
|
||||
`ISOKIT_LLM_URL`, `ISOKIT_LLM_KEY`, `ISOKIT_LLM_MODEL`, or the window's ⚙,
|
||||
which saves them in `%APPDATA%\isokit\agent.json` (`~/.config/isokit` elsewhere).
|
||||
- The web Studio calls the endpoint from the page; the key stays in that
|
||||
browser. The endpoint must allow the page (CORS, preflight included), or the
|
||||
site relays it: https://isokit.buildfor.life/ is built with
|
||||
`--agent-url /llm/v1`, a same-site path that Caddy forwards to B4L's
|
||||
inference server (`deploy/Caddyfile`). The relay adds no key: each browser
|
||||
enters its own once.
|
||||
|
||||
### Agents (MCP)
|
||||
|
||||
While the Studio runs, it also serves MCP over HTTP at `http://127.0.0.1:7878/mcp`.
|
||||
@@ -127,7 +156,7 @@ storey.
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `isokit new <dir>` | starter project |
|
||||
| `isokit studio [dir] [--port N] [--no-open]` | GUI editor + `/mcp` + `/view` live preview |
|
||||
| `isokit studio [dir] [--port N] [--no-open]` | GUI editor + `/mcp` + `/view` live preview + the agent window's link to the model |
|
||||
| `isokit mcp [dir]` | MCP over stdio |
|
||||
| `isokit build [dir] [-o file]` | self-contained HTML (all levels × 4 rotations) |
|
||||
| `isokit render [dir] -o x.svg [--level gf] [--rot 2] [--light]` | one view as SVG |
|
||||
@@ -144,7 +173,7 @@ crates/isokit-project project folder, document ops, export, check, Workspace (u
|
||||
crates/isokit-cli the `isokit` binary: Studio HTTP server, MCP stdio, build/render/check
|
||||
crates/isokit-wasm the engine for the browser (wasm-bindgen): a project in memory behind the Studio API
|
||||
viewer/ isokit-view.js/.css: pan, zoom, levels, rotation, tooltips (any page can embed it)
|
||||
studio/ the Studio GUI (served by `isokit studio`, and by the web Studio)
|
||||
studio/ the Studio GUI (served by `isokit studio`, and by the web Studio); agent.js = agent window
|
||||
web/ web Studio shell: WASM backend for studio.js, projects, folders, zip, code editor
|
||||
examples/demo demo project
|
||||
deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1)
|
||||
@@ -165,12 +194,16 @@ Plans: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
```bash
|
||||
cargo test # engine, runtime, ops, demo, MCP
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
isokit studio examples/demo --port 7890 --no-open &
|
||||
uv run --with playwright python tools/check_studio.py <shot-dir> http://127.0.0.1:7890 # GUI + MCP end to end (Edge)
|
||||
ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DIR=<scratch> \
|
||||
isokit studio <copy of examples/demo> --port 7890 --no-open & # agent window on tools/mock_llm.py
|
||||
uv run --with playwright python tools/check_studio.py <shot-dir> http://127.0.0.1:7890 # GUI + MCP + agent window end to end (Edge)
|
||||
cargo clippy -p isokit-wasm --target wasm32-unknown-unknown -- -D warnings
|
||||
python tools/build_web.py && uv run --with playwright python tools/check_web.py <shot-dir> # web Studio end to end
|
||||
```
|
||||
|
||||
The agent window checks run on `tools/mock_llm.py`, a scripted stand-in for an
|
||||
OpenAI-compatible server that the check scripts start on port 7932.
|
||||
|
||||
`python tools/build_web.py --skip-cargo` re-copies the front end (studio/,
|
||||
viewer/, web/) without rebuilding the WASM. Code that runs in the browser must
|
||||
not use `std::time` (it traps on wasm32-unknown-unknown): use `web_time`.
|
||||
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,261 @@
|
||||
//! The Studio's agent window talks to a language model through these routes,
|
||||
//! so the endpoint key stays on this machine and the browser needs no CORS
|
||||
//! permission from the endpoint (any OpenAI-compatible `/v1` API: vLLM,
|
||||
//! llama.cpp, Ollama, LM Studio, a hosted one):
|
||||
//!
|
||||
//! - `GET /api/agent/config` endpoint, model, whether a key is set (never the key)
|
||||
//! - `POST /api/agent/config` save `{ url, model, think, key }` (key "" forgets it)
|
||||
//! - `GET /api/agent/models` the endpoint's `/models`
|
||||
//! - `POST /api/agent/chat` the endpoint's `/chat/completions`, streamed through
|
||||
//!
|
||||
//! Settings come from `ISOKIT_LLM_URL`, `ISOKIT_LLM_KEY` and `ISOKIT_LLM_MODEL`,
|
||||
//! else from `agent.json` in the user's config folder (`%APPDATA%\isokit`,
|
||||
//! `~/.config/isokit`; `ISOKIT_CONFIG_DIR` moves it), which the window writes.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tiny_http::Request;
|
||||
|
||||
/// B4L's inference server (vLLM, Qwen); any OpenAI-compatible base URL works.
|
||||
pub const DEFAULT_URL: &str = "https://inference.p0.contact/v1";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Settings {
|
||||
pub url: String,
|
||||
pub key: String,
|
||||
pub model: String,
|
||||
pub think: bool,
|
||||
/// where url / key came from: "env", "file" or "default" / ""
|
||||
pub url_from: &'static str,
|
||||
pub key_from: &'static str,
|
||||
}
|
||||
|
||||
pub fn config_file() -> Option<PathBuf> {
|
||||
if let Some(d) = var("ISOKIT_CONFIG_DIR") {
|
||||
return Some(PathBuf::from(d).join("agent.json"));
|
||||
}
|
||||
let base = if cfg!(windows) {
|
||||
var("APPDATA").map(PathBuf::from)
|
||||
} else {
|
||||
var("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| var("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
};
|
||||
base.map(|b| b.join("isokit").join("agent.json"))
|
||||
}
|
||||
|
||||
fn var(k: &str) -> Option<String> {
|
||||
std::env::var(k)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn read_file() -> Map<String, Value> {
|
||||
config_file()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str::<Map<String, Value>>(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let f = read_file();
|
||||
let fs = |k: &str| {
|
||||
f.get(k)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
let (url, url_from) = match (var("ISOKIT_LLM_URL"), fs("url")) {
|
||||
(Some(u), _) => (u, "env"),
|
||||
(None, Some(u)) => (u, "file"),
|
||||
_ => (DEFAULT_URL.to_string(), "default"),
|
||||
};
|
||||
let (key, key_from) = match (var("ISOKIT_LLM_KEY"), fs("key")) {
|
||||
(Some(k), _) => (k, "env"),
|
||||
(None, Some(k)) => (k, "file"),
|
||||
_ => (String::new(), ""),
|
||||
};
|
||||
Settings {
|
||||
url,
|
||||
key,
|
||||
model: var("ISOKIT_LLM_MODEL")
|
||||
.or_else(|| fs("model"))
|
||||
.unwrap_or_default(),
|
||||
think: f.get("think").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
url_from,
|
||||
key_from,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the page may see: everything but the key.
|
||||
pub fn public(s: &Settings) -> Value {
|
||||
json!({
|
||||
"url": s.url, "url_from": s.url_from, "model": s.model, "think": s.think,
|
||||
"has_key": !s.key.is_empty(), "key_from": s.key_from,
|
||||
"file": config_file().map(|p| p.display().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge the window's settings into the config file. A missing field stays
|
||||
/// as it is; `key: ""` forgets the key.
|
||||
pub fn save(body: &Value) -> Result<Value, String> {
|
||||
let path = config_file().ok_or("no config folder (set ISOKIT_CONFIG_DIR)")?;
|
||||
let mut f = read_file();
|
||||
for k in ["url", "model", "key"] {
|
||||
if let Some(v) = body.get(k).and_then(|v| v.as_str()) {
|
||||
let v = v.trim();
|
||||
if v.is_empty() {
|
||||
f.remove(k);
|
||||
} else {
|
||||
f.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(b) = body.get("think").and_then(|v| v.as_bool()) {
|
||||
f.insert("think".into(), json!(b));
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&Value::Object(f)).unwrap_or_default();
|
||||
std::fs::write(&path, text + "\n").map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(public(&load()))
|
||||
}
|
||||
|
||||
/// Forward a request to `<url><path>` on its own thread (an answer can take
|
||||
/// minutes; the Studio keeps serving meanwhile) and stream the answer back.
|
||||
pub fn forward(req: Request, path: &'static str, body: Option<String>) {
|
||||
std::thread::spawn(move || {
|
||||
let s = load();
|
||||
let url = format!("{}{}", s.url.trim_end_matches('/'), path);
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_connect(Some(Duration::from_secs(15)))
|
||||
.timeout_recv_response(Some(Duration::from_secs(600)))
|
||||
.user_agent(concat!("isokit-studio/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.into();
|
||||
let auth = format!("Bearer {}", s.key);
|
||||
let res = match body {
|
||||
Some(b) => {
|
||||
let b = with_model(&b, &s.model);
|
||||
let mut r = agent.post(&url).header("Content-Type", "application/json");
|
||||
if !s.key.is_empty() {
|
||||
r = r.header("Authorization", &auth);
|
||||
}
|
||||
r.send(b.as_bytes())
|
||||
}
|
||||
None => {
|
||||
let mut r = agent.get(&url);
|
||||
if !s.key.is_empty() {
|
||||
r = r.header("Authorization", &auth);
|
||||
}
|
||||
r.call()
|
||||
}
|
||||
};
|
||||
match res {
|
||||
Ok(resp) => stream_back(req, resp),
|
||||
Err(e) => {
|
||||
let msg = format!("{url}: {e}");
|
||||
let body = json!({ "error": { "message": msg } }).to_string();
|
||||
let r = tiny_http::Response::from_string(body)
|
||||
.with_status_code(502)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes("Content-Type", "application/json")
|
||||
.expect("valid header"),
|
||||
);
|
||||
let _ = req.respond(r);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The configured model when the page left it open.
|
||||
fn with_model(body: &str, model: &str) -> String {
|
||||
match serde_json::from_str::<Value>(body) {
|
||||
Ok(Value::Object(mut m)) => {
|
||||
let open = m
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_none_or(|s| s.is_empty());
|
||||
if open && !model.is_empty() {
|
||||
m.insert("model".into(), json!(model));
|
||||
}
|
||||
Value::Object(m).to_string()
|
||||
}
|
||||
_ => body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the endpoint's answer to the page as it arrives (chunked, flushed per
|
||||
/// read: tiny_http's own chunked writer would hold back 8 kB at a time).
|
||||
fn stream_back(req: Request, resp: ureq::http::Response<ureq::Body>) {
|
||||
let status = resp.status();
|
||||
let ctype = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/json")
|
||||
.to_string();
|
||||
let sse = ctype.starts_with("text/event-stream");
|
||||
let mut body = resp.into_body().into_reader();
|
||||
let mut w = req.into_writer();
|
||||
let head = format!(
|
||||
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nCache-Control: no-store\r\nX-Accel-Buffering: no\r\nTransfer-Encoding: chunked\r\n\r\n",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or(""),
|
||||
ctype
|
||||
);
|
||||
if w.write_all(head.as_bytes())
|
||||
.and_then(|_| w.flush())
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut buf = vec![0u8; 16 << 10];
|
||||
loop {
|
||||
let chunk: Vec<u8> = match body.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => buf[..n].to_vec(),
|
||||
// the endpoint broke off: tell a streaming page why
|
||||
Err(e) if sse => {
|
||||
let ev = json!({ "error": { "message": format!("the endpoint broke off: {e}") } });
|
||||
format!("data: {ev}\n\n").into_bytes()
|
||||
}
|
||||
Err(_) => break,
|
||||
};
|
||||
let last = sse && chunk.starts_with(b"data: {\"error\"");
|
||||
let ok = write!(w, "{:x}\r\n", chunk.len())
|
||||
.and_then(|_| w.write_all(&chunk))
|
||||
.and_then(|_| w.write_all(b"\r\n"))
|
||||
.and_then(|_| w.flush());
|
||||
if ok.is_err() {
|
||||
return; // the page stopped reading (Stop): dropping the reader ends the request upstream
|
||||
}
|
||||
if last {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = w.write_all(b"0\r\n\r\n").and_then(|_| w.flush());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_fills_in_only_when_open() {
|
||||
let b = with_model(r#"{"model":"","messages":[]}"#, "qwen");
|
||||
assert_eq!(serde_json::from_str::<Value>(&b).unwrap()["model"], "qwen");
|
||||
let b = with_model(r#"{"model":"other"}"#, "qwen");
|
||||
assert_eq!(serde_json::from_str::<Value>(&b).unwrap()["model"], "other");
|
||||
let b = with_model(r#"{"messages":[]}"#, "");
|
||||
assert!(serde_json::from_str::<Value>(&b)
|
||||
.unwrap()
|
||||
.get("model")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
//! `isokit`: the command line.
|
||||
//!
|
||||
//! isokit new <dir> start a project
|
||||
//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp)
|
||||
//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp,
|
||||
//! and the agent window's link to a language model)
|
||||
//! isokit mcp [dir] MCP server over stdio for AI agents
|
||||
//! isokit build [dir] self-contained HTML page with every view
|
||||
//! isokit render [dir] -o x.svg one view as SVG
|
||||
//! isokit check [dir] lint the project
|
||||
//! isokit prefabs [dir] list the object types
|
||||
|
||||
mod agent;
|
||||
mod serve;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -36,6 +38,10 @@ enum Cmd {
|
||||
title: Option<String>,
|
||||
},
|
||||
/// Open the Studio (GUI editor) in the browser
|
||||
///
|
||||
/// Its agent window chats with a language model on an OpenAI-compatible
|
||||
/// endpoint: set it up in the window, or with ISOKIT_LLM_URL,
|
||||
/// ISOKIT_LLM_KEY and ISOKIT_LLM_MODEL.
|
||||
Studio {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub const STUDIO_HTML: &str = include_str!("../../../studio/index.html");
|
||||
pub const STUDIO_JS: &str = include_str!("../../../studio/studio.js");
|
||||
pub const STUDIO_CSS: &str = include_str!("../../../studio/studio.css");
|
||||
pub const STUDIO_FAVICON: &str = include_str!("../../../studio/favicon.svg");
|
||||
pub const STUDIO_AGENT_JS: &str = include_str!("../../../studio/agent.js");
|
||||
|
||||
/// Asset by its path in the repository ("viewer/isokit-view.js",
|
||||
/// "studio/studio.js"), from disk when `ISOKIT_ASSETS` is set.
|
||||
@@ -28,6 +29,7 @@ pub fn get(path: &str) -> Option<Cow<'static, str>> {
|
||||
"studio/studio.js" => STUDIO_JS,
|
||||
"studio/studio.css" => STUDIO_CSS,
|
||||
"studio/favicon.svg" => STUDIO_FAVICON,
|
||||
"studio/agent.js" => STUDIO_AGENT_JS,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Cow::Borrowed(s))
|
||||
|
||||
+19
-1
@@ -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"
|
||||
|
||||
# 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
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,19 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
|
||||
- [x] `tools/build_web.py` (static output), `tools/check_web.py` (44 checks, Edge)
|
||||
- [ ] Deploy a hosted copy; render in a Web Worker for very large scenes
|
||||
|
||||
## Agent window (done)
|
||||
|
||||
- [x] ✦ Agent in the Studio: chat with a model on an OpenAI-compatible endpoint (default: B4L's
|
||||
inference server) that edits the scene with the MCP tools + page tools (select, view);
|
||||
streamed answers and reasoning, tool calls with results, clickable object ids, Stop, Undo
|
||||
- [x] `isokit studio` relays the calls (`/api/agent/*`, key stays on the machine, no CORS);
|
||||
settings from `ISOKIT_LLM_*` or `agent.json` in the user's config folder
|
||||
- [x] Web Studio calls the endpoint from the page; isokit.buildfor.life relays `/llm/` to the
|
||||
inference server (Caddy) so the page needs no CORS from it
|
||||
- [x] `tools/mock_llm.py` + `tools/agent_checks.py` in both Studio checks
|
||||
- [ ] Keep the conversation per project across reloads; attach a rendered view (PNG, Phase 4)
|
||||
for vision models
|
||||
|
||||
## Phase 2: richer editing
|
||||
|
||||
- [ ] Interior prefabs (desk, table with chairs, shelf, machine, bed, sofa, partition walls) and a
|
||||
|
||||
+609
@@ -0,0 +1,609 @@
|
||||
/* isokit Studio: the agent window. A chat with a language model on an
|
||||
* OpenAI-compatible endpoint (by default B4L's inference server) that edits
|
||||
* the open scene with the isokit MCP tools, plus two page tools (point at an
|
||||
* object, turn the view). Its edits show on the map at once; Undo reverts them.
|
||||
*
|
||||
* Under `isokit studio` the model is reached through the Studio server
|
||||
* (/api/agent/*, crates/isokit-cli/src/agent.rs): the key stays on the machine
|
||||
* and the endpoint needs no CORS. The web Studio calls the endpoint from the
|
||||
* page with a key kept in this browser; a build can name a same-site relay as
|
||||
* the default endpoint (<meta name="isokit-agent" content="/llm/v1">).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var DEFAULT_URL = "https://inference.p0.contact/v1";
|
||||
var MAX_STEPS = 32, MAX_RESULT = 24000;
|
||||
var LS_CFG = "isokit-agent", LS_UI = "isokit-agent-ui";
|
||||
var SUGGEST = ["What is in this scene?", "Plant a row of trees along the road", "Add a carport with two cars next to the main building", "Make the selected building one storey taller"];
|
||||
var A = { cfg: null, models: [], modelsErr: null, msgs: [], busy: false, ctl: null, tools: null, instructions: "", rpc: 0, ui: lsGet(LS_UI) || {} };
|
||||
|
||||
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").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 <think> in the text). */
|
||||
function split(m) {
|
||||
var c = m.content || "", r = m.reasoning || "";
|
||||
var i = c.indexOf("<think>");
|
||||
if (i >= 0) {
|
||||
var j = c.indexOf("</think>", i);
|
||||
r += (r ? "\n" : "") + c.slice(i + 7, j < 0 ? c.length : j);
|
||||
c = c.slice(0, i) + (j < 0 ? "" : c.slice(j + 8));
|
||||
}
|
||||
return { reasoning: r.trim(), content: c.replace(/^\s+/, "") };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ tools
|
||||
function mcp(msg) {
|
||||
if (web()) {
|
||||
// reads go straight to the engine; edits through the page, which saves them
|
||||
return Promise.resolve(msg.method === "tools/call" ? window.isokit.mcp(msg) : JSON.parse(window.isokit.studio.mcp(JSON.stringify(msg)) || "null"));
|
||||
}
|
||||
return fetch("/mcp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(msg) })
|
||||
.then(function (r) { return r.status === 202 ? null : r.json(); });
|
||||
}
|
||||
var PAGE = {
|
||||
select_object: {
|
||||
description: "Point at an object on the user's screen: select it in the Studio and zoom the view to it. Without id: clear the selection.",
|
||||
parameters: { type: "object", properties: { id: { type: "string" }, zoom: { type: "boolean", description: "zoom to it (default true)" } }, required: [] },
|
||||
run: function (a) {
|
||||
var s = st();
|
||||
if (!a.id) { s.select(null); return "selection cleared"; }
|
||||
if (!s.obj(a.id)) throw new Error('no object "' + a.id + '"');
|
||||
s.select(a.id);
|
||||
if (a.zoom !== false) s.view.focus(a.id, 3);
|
||||
return "selected " + a.id + " on the user's screen";
|
||||
}
|
||||
},
|
||||
set_view: {
|
||||
description: "Turn the user's view (rotation 0-3, quarter turns) or show another level (level ids from project_info; storeys are drawn cut open).",
|
||||
parameters: { type: "object", properties: { rotation: { type: "integer", minimum: 0, maximum: 3 }, level: { type: "string" } }, required: [] },
|
||||
run: function (a) {
|
||||
var s = st(), lv = s.S.meta.levels || [];
|
||||
if (a.level) {
|
||||
if (!lv.some(function (l) { return l.id === a.level; })) throw new Error('no level "' + a.level + '" (levels: ' + lv.map(function (l) { return l.id; }).join(", ") + ")");
|
||||
s.view.setLevel(a.level);
|
||||
}
|
||||
if (typeof a.rotation === "number") s.view.setRotation(((Math.round(a.rotation) % 4) + 4) % 4);
|
||||
return "the user now sees level " + s.view.getLevel() + ", rotation " + s.view.getRotation();
|
||||
}
|
||||
}
|
||||
};
|
||||
function loadTools() {
|
||||
if (A.tools) return Promise.resolve(A.tools);
|
||||
var init = { jsonrpc: "2.0", id: ++A.rpc, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "isokit-agent-window", version: "1" } } };
|
||||
return Promise.all([mcp(init), mcp({ jsonrpc: "2.0", id: ++A.rpc, method: "tools/list" })]).then(function (rs) {
|
||||
A.instructions = (rs[0] && rs[0].result && rs[0].result.instructions) || "";
|
||||
var list = ((rs[1] && rs[1].result && rs[1].result.tools) || []).map(function (t) {
|
||||
return { type: "function", "function": { name: t.name, description: t.description, parameters: t.inputSchema } };
|
||||
});
|
||||
Object.keys(PAGE).forEach(function (k) { list.push({ type: "function", "function": { name: k, description: PAGE[k].description, parameters: PAGE[k].parameters } }); });
|
||||
A.tools = list;
|
||||
return list;
|
||||
});
|
||||
}
|
||||
function parseArgs(s) {
|
||||
if (s && typeof s === "object") return s;
|
||||
if (!s || !String(s).trim()) return {};
|
||||
return JSON.parse(s);
|
||||
}
|
||||
/** -> { error, text } */
|
||||
function runTool(name, args) {
|
||||
var a;
|
||||
try { a = parseArgs(args); } catch (e) { return Promise.resolve({ error: true, text: "The arguments are not valid JSON (" + e.message + "): send the call again with a JSON object." }); }
|
||||
if (PAGE[name]) {
|
||||
try { return Promise.resolve({ error: false, text: PAGE[name].run(a) }); } catch (e) { return Promise.resolve({ error: true, text: e.message || String(e) }); }
|
||||
}
|
||||
return mcp({ jsonrpc: "2.0", id: ++A.rpc, method: "tools/call", params: { name: name, arguments: a } }).then(function (r) {
|
||||
if (!r) return { error: true, text: "no answer from the scene" };
|
||||
if (r.error) return { error: true, text: r.error.message || JSON.stringify(r.error) };
|
||||
var res = r.result || {};
|
||||
return { error: !!res.isError, text: (res.content || []).map(function (c) { return c.text || ""; }).join("\n") };
|
||||
}, function (e) { return { error: true, text: String(e && e.message || e) }; });
|
||||
}
|
||||
/** The Studio redraws as soon as an edit lands (its poll would take up to a second). */
|
||||
function follow() {
|
||||
var s = st();
|
||||
return Promise.resolve(s.api("/api/version")).then(function (r) { if (r.version !== s.S.version) return s.reloadAll(); }).catch(function () { /* the poll catches up */ });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the conversation
|
||||
function compact(o) {
|
||||
var c = { id: o.id, type: o.type, x: o.x, y: o.y };
|
||||
["z", "rot", "name", "tag", "props", "levels", "locked", "hidden"].forEach(function (k) { if (o[k] !== undefined && o[k] !== null && o[k] !== "") c[k] = o[k]; });
|
||||
return c;
|
||||
}
|
||||
function system() {
|
||||
var s = st(), S = s.S, sel = S.sel && s.obj(S.sel), v = s.view;
|
||||
return {
|
||||
role: "system",
|
||||
content: [
|
||||
"You are the agent inside the isokit Studio, the page where the user edits an isometric scene. You change the scene with the tools; every edit appears on the user's map at once, and the user can take any edit back with Undo (Ctrl+Z).",
|
||||
A.instructions,
|
||||
"How to work: look before you build (list_objects shows what is where with world boxes; list_prefabs with a type gives its props). Place things inside the site, on free ground, and use the boxes to avoid overlaps. Put related edits into one apply_ops call so they undo as one step. After bigger changes, check with render_view or check_project. Use select_object to show the user what you mean. Ask back when a request is unclear instead of guessing at big changes.",
|
||||
"Answer in the user's language, short and plain, without tables: say what you changed and give object ids in backticks (the user can click them).",
|
||||
"The Studio right now: project \"" + S.meta.title + "\", " + (S.doc.objects || []).length + " objects; view level \"" + v.getLevel() + "\", rotation " + v.getRotation() + "; snap step " + (s.snapStep() || "off") + ". " +
|
||||
(sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.")
|
||||
].filter(Boolean).join("\n\n")
|
||||
};
|
||||
}
|
||||
function send(text) {
|
||||
text = String(text || "").trim();
|
||||
if (!text || A.busy) return;
|
||||
A.msgs.push({ role: "user", content: text });
|
||||
addMsg("ag-user", esc(text).replace(/\n/g, "<br>"));
|
||||
busy(true);
|
||||
var ctl = new AbortController(), step = 0, pending = null;
|
||||
A.ctl = ctl;
|
||||
function turn() {
|
||||
if (step++ >= MAX_STEPS) { note("Stopped after " + MAX_STEPS + " steps. Say \u201ccontinue\u201d to go on."); return null; }
|
||||
var view = botView();
|
||||
return complete([system()].concat(A.msgs), ctl.signal, view.update).then(function (res) {
|
||||
view.done(res.msg);
|
||||
var m = split(res.msg), calls = res.msg.tool_calls.filter(function (c) { return c && c["function"].name; });
|
||||
var out = { role: "assistant", content: m.content || "" };
|
||||
if (calls.length) {
|
||||
out.tool_calls = calls.map(function (c, i) { return { id: c.id || "call_" + Date.now().toString(36) + "_" + i, type: "function", "function": { name: c["function"].name, arguments: c["function"].arguments || "{}" } }; });
|
||||
}
|
||||
A.msgs.push(out);
|
||||
if (!calls.length) {
|
||||
if (res.finish === "length") note("The answer hit the length limit and was cut off.");
|
||||
if (!m.content) note("The model gave no answer.");
|
||||
return null;
|
||||
}
|
||||
pending = out.tool_calls.slice();
|
||||
return out.tool_calls.reduce(function (p, c) {
|
||||
return p.then(function () {
|
||||
if (ctl.signal.aborted) return;
|
||||
var chip = toolView(c);
|
||||
return runTool(c["function"].name, c["function"].arguments).then(function (r) {
|
||||
chip.done(r);
|
||||
A.msgs.push({ role: "tool", tool_call_id: c.id, content: clip(r.text, MAX_RESULT) });
|
||||
pending.shift();
|
||||
});
|
||||
});
|
||||
}, Promise.resolve()).then(follow).then(function () { if (!ctl.signal.aborted) return turn(); });
|
||||
}, function (e) {
|
||||
view.done(null);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
ready().then(turn).catch(function (e) {
|
||||
if (ctl.signal.aborted || (e && e.name === "AbortError")) return;
|
||||
error(e && e.message || String(e));
|
||||
}).then(function () {
|
||||
// every tool call needs an answer, or the endpoint refuses the next request
|
||||
(pending || []).forEach(function (c) { A.msgs.push({ role: "tool", tool_call_id: c.id, content: "not run: the turn was stopped" }); });
|
||||
if (ctl.signal.aborted) { note("Stopped."); follow(); }
|
||||
busy(false);
|
||||
A.ctl = null;
|
||||
});
|
||||
}
|
||||
function stop() { if (A.ctl) A.ctl.abort(); }
|
||||
/** Settings and tools loaded (once; a failed start is tried again on the next message). */
|
||||
function ready() {
|
||||
if (!A.ready) {
|
||||
A.ready = loadCfg().then(function () { who(); return Promise.all([loadTools(), connect()]); });
|
||||
A.ready.catch(function () { A.ready = null; });
|
||||
}
|
||||
return A.ready;
|
||||
}
|
||||
function newChat() { stop(); A.msgs = []; renderEmpty(); $("agtext").focus(); }
|
||||
|
||||
// ------------------------------------------------------------ chat view
|
||||
function log() { return $("aglog"); }
|
||||
function nearEnd() { var l = log(); return l.scrollHeight - l.scrollTop - l.clientHeight < 60; }
|
||||
function addMsg(cls, html) {
|
||||
var e = log().querySelector(".ag-empty");
|
||||
if (e) e.remove();
|
||||
var d = document.createElement("div");
|
||||
d.className = cls;
|
||||
d.innerHTML = html;
|
||||
log().appendChild(d);
|
||||
log().scrollTop = log().scrollHeight;
|
||||
return d;
|
||||
}
|
||||
function note(t) { addMsg("ag-note", esc(t)); }
|
||||
function error(t) {
|
||||
var d = addMsg("ag-err", esc(t) + ' <button type="button" class="ag-link" data-act="settings">Settings</button> <button type="button" class="ag-link" data-act="retry">Try again</button>');
|
||||
return d;
|
||||
}
|
||||
function renderEmpty() {
|
||||
log().innerHTML = '<div class="ag-empty"><p>Ask for changes in plain words. The agent edits the scene with the same tools as you, and <b>Undo</b> takes any of its steps back.</p>' +
|
||||
'<div class="ag-sugs">' + SUGGEST.map(function (s) { return '<button type="button" class="ag-sug">' + esc(s) + "</button>"; }).join("") + "</div></div>";
|
||||
}
|
||||
function md(src) {
|
||||
var html = "", list = null, code = null;
|
||||
function close() { if (list) { html += "</" + list + ">"; list = null; } }
|
||||
function inline(s) {
|
||||
return esc(s).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>").replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>");
|
||||
}
|
||||
src.replace(/\r/g, "").split("\n").forEach(function (line) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
if (code === null) { close(); code = []; } else { html += "<pre>" + esc(code.join("\n")) + "</pre>"; code = null; }
|
||||
return;
|
||||
}
|
||||
if (code !== null) { code.push(line); return; }
|
||||
var m = /^\s*(?:([-*•])|(\d+)[.)])\s+(.*)$/.exec(line);
|
||||
if (m) {
|
||||
var kind = m[1] ? "ul" : "ol";
|
||||
if (list !== kind) { close(); html += "<" + kind + ">"; list = kind; }
|
||||
html += "<li>" + inline(m[3]) + "</li>";
|
||||
return;
|
||||
}
|
||||
close();
|
||||
if (!line.trim()) return;
|
||||
var h = /^#{1,6}\s+(.*)$/.exec(line);
|
||||
html += h ? "<p><b>" + inline(h[1]) + "</b></p>" : "<p>" + inline(line) + "</p>";
|
||||
});
|
||||
close();
|
||||
if (code !== null) html += "<pre>" + esc(code.join("\n")) + "</pre>";
|
||||
return html;
|
||||
}
|
||||
/** `ids` of objects in the scene become links that select them. */
|
||||
function linkIds(el) {
|
||||
var s = st();
|
||||
Array.prototype.forEach.call(el.querySelectorAll("code"), function (c) {
|
||||
var id = c.textContent.trim();
|
||||
if (s.obj(id)) { c.classList.add("ag-obj"); c.setAttribute("data-obj", id); c.title = "Select and zoom to it"; }
|
||||
});
|
||||
}
|
||||
function botView() {
|
||||
var d = addMsg("ag-msg ag-bot ag-live", '<details class="ag-think" hidden><summary>Thinking…</summary><div></div></details><div class="ag-text"></div>');
|
||||
var think = d.querySelector(".ag-think"), tt = think.querySelector("div"), tx = d.querySelector(".ag-text");
|
||||
var t0 = Date.now(), t1 = 0, last = null, raf = 0;
|
||||
function paint() {
|
||||
raf = 0;
|
||||
if (!last) return;
|
||||
var stick = nearEnd(), m = split(last);
|
||||
if (m.reasoning) { think.hidden = false; tt.textContent = m.reasoning; }
|
||||
tx.innerHTML = md(m.content);
|
||||
if (stick) log().scrollTop = log().scrollHeight;
|
||||
}
|
||||
return {
|
||||
update: function (m) {
|
||||
last = m;
|
||||
if (!t1 && (split(m).content || m.tool_calls.length)) t1 = Date.now();
|
||||
if (!raf) raf = requestAnimationFrame(paint);
|
||||
},
|
||||
done: function (m) { // m null: broken off, keep what came
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
if (m) last = m;
|
||||
paint();
|
||||
d.classList.remove("ag-live");
|
||||
var p = last ? split(last) : { reasoning: "", content: "" };
|
||||
if (p.reasoning) think.querySelector("summary").textContent = "Thought for " + Math.max(1, Math.round(((t1 || Date.now()) - t0) / 1000)) + " s";
|
||||
if (!p.content && !p.reasoning) d.remove();
|
||||
else linkIds(tx);
|
||||
}
|
||||
};
|
||||
}
|
||||
function num(v) { return typeof v === "number" ? +v.toFixed(2) : v; }
|
||||
function summary(n, a) {
|
||||
var ops = a.ops || [];
|
||||
switch (n) {
|
||||
case "add_object": return (a.type || "?") + (a.x !== undefined ? " at " + num(a.x) + ", " + num(a.y) : "");
|
||||
case "update_object": return (a.id || "?") + ": " + Object.keys(a).filter(function (k) { return k !== "id"; }).map(function (k) { return k === "props" ? Object.keys(a.props || {}).join(", ") : k; }).join(", ");
|
||||
case "remove_object": case "get_object": case "duplicate_object": case "select_object": return a.id || "";
|
||||
case "apply_ops": return ops.length + " edit" + (ops.length === 1 ? "" : "s") + ": " + ops.slice(0, 4).map(function (o) { return (o.op || "?") + " " + (o.type || o.id || ""); }).join(", ") + (ops.length > 4 ? ", …" : "");
|
||||
case "list_prefabs": return a.type || a.category || "";
|
||||
case "list_objects": return a.type || "";
|
||||
case "set_params": return Object.keys(a.values || {}).join(", ");
|
||||
case "render_view": case "set_view": return [a.level, a.rotation !== undefined ? "rotation " + a.rotation : "", a.format].filter(Boolean).join(", ");
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
function touched(n, a, res) {
|
||||
if (res.error) return null;
|
||||
try {
|
||||
if (n === "add_object" || n === "duplicate_object") return JSON.parse(res.text).id || null;
|
||||
if (n === "apply_ops") { var ids = (JSON.parse(res.text).ids || []).filter(Boolean); return ids[ids.length - 1] || null; }
|
||||
} catch (e) { return null; }
|
||||
if (n === "update_object" || n === "select_object" || n === "get_object") return a.id || null;
|
||||
return null;
|
||||
}
|
||||
function toolView(c) {
|
||||
var n = c["function"].name, a = {};
|
||||
try { a = parseArgs(c["function"].arguments); } catch (e) { /* shown raw */ }
|
||||
var d = addMsg("ag-tool", '<details><summary><b>' + esc(n) + "</b> <span>" + esc(summary(n, a)) + '</span><em>…</em></summary><pre class="ag-args"></pre><pre class="ag-res"></pre></details>');
|
||||
d.querySelector(".ag-args").textContent = typeof c["function"].arguments === "string" && !Object.keys(a).length ? c["function"].arguments : JSON.stringify(a, null, 1);
|
||||
return {
|
||||
done: function (r) {
|
||||
var em = d.querySelector("em"), id = touched(n, a, r);
|
||||
d.classList.add(r.error ? "ag-bad" : "ag-ok");
|
||||
em.textContent = r.error ? "✗" : "✓";
|
||||
em.title = r.error ? clip(r.text, 300) : "done";
|
||||
d.querySelector(".ag-res").textContent = clip(r.text, 6000);
|
||||
if (id) { // the Studio may not have reloaded the new object yet; a click checks
|
||||
var link = '<code class="ag-obj" data-obj="' + esc(id) + '" title="Select and zoom to it">' + esc(id) + "</code>", sp = d.querySelector("summary span");
|
||||
// "id: x, y" -> the id itself becomes the link; else it follows the summary
|
||||
if (sp.textContent.indexOf(id) === 0) { sp.textContent = sp.textContent.slice(id.length); sp.insertAdjacentHTML("beforebegin", link); }
|
||||
else em.insertAdjacentHTML("beforebegin", link);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
function busy(on) {
|
||||
A.busy = on;
|
||||
var b = $("agsend");
|
||||
b.textContent = on ? "Stop" : "Send";
|
||||
b.classList.toggle("ag-stop", on);
|
||||
b.title = on ? "Stop the agent" : "Send (Enter)";
|
||||
$("agwin").classList.toggle("ag-busy", on);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ settings form
|
||||
function who() {
|
||||
var b = $("agwho");
|
||||
if (!A.cfg) { b.innerHTML = "…"; return; }
|
||||
var ok = !A.modelsErr && A.models.length;
|
||||
b.innerHTML = '<i class="ag-dot ' + (ok ? "ag-on" : A.modelsErr ? "ag-off" : "") + '"></i>' + esc((model() || "no model") + " · " + host());
|
||||
b.title = A.modelsErr ? "Not connected: " + A.modelsErr : "Model and endpoint (⚙ to change)";
|
||||
}
|
||||
function fillSettings() {
|
||||
var c = A.cfg, f = $("agsetf");
|
||||
$("agurl").value = c.url;
|
||||
$("agurl").disabled = c.url_from === "env";
|
||||
$("agurl").title = c.url_from === "env" ? "Set by ISOKIT_LLM_URL" : "OpenAI-compatible base URL, usually ending in /v1";
|
||||
$("agkey").value = "";
|
||||
$("agkey").disabled = c.key_from === "env";
|
||||
$("agkey").placeholder = c.key_from === "env" ? "set by ISOKIT_LLM_KEY" : c.has_key ? "saved (type to replace)" : "none (most hosted endpoints need one)";
|
||||
$("agforget").hidden = !c.has_key || c.key_from === "env";
|
||||
var sel = $("agmodel"), names = A.models.slice();
|
||||
if (c.model && names.indexOf(c.model) < 0) names.unshift(c.model);
|
||||
sel.innerHTML = '<option value="">' + esc(A.models.length ? "first offered (" + A.models[0] + ")" : "first offered") + "</option>" +
|
||||
names.map(function (m) { return "<option" + (m === c.model ? " selected" : "") + ">" + esc(m) + "</option>"; }).join("");
|
||||
$("agthink").checked = c.think !== false;
|
||||
$("agsetnote").innerHTML = web()
|
||||
? "Kept in this browser only." + (siteDefault() !== DEFAULT_URL ? " Default endpoint: " + esc(siteDefault()) + " (this site's relay to B4L's inference server)." : "")
|
||||
: "Kept by the Studio server" + (c.file ? " in <code>" + esc(c.file) + "</code>" : "") + "; the key never reaches this page.";
|
||||
setMsg(A.modelsErr ? A.modelsErr : A.models.length ? "Connected: " + A.models.length + " model" + (A.models.length === 1 ? "" : "s") : "", !!A.modelsErr);
|
||||
f.hidden = false;
|
||||
$("agset").setAttribute("aria-pressed", "true");
|
||||
}
|
||||
function setMsg(t, bad) { var m = $("agsetmsg"); m.textContent = t; m.classList.toggle("ag-badtext", !!bad); m.title = t; }
|
||||
function hideSettings() { $("agsetf").hidden = true; $("agset").setAttribute("aria-pressed", "false"); }
|
||||
function saveSettings(e) {
|
||||
e.preventDefault();
|
||||
var v = { model: $("agmodel").value, think: $("agthink").checked };
|
||||
if (!$("agurl").disabled) v.url = $("agurl").value.trim();
|
||||
if ($("agkey").value.trim()) v.key = $("agkey").value.trim();
|
||||
setMsg("Saving…");
|
||||
saveCfg(v).then(connect).then(function () { fillSettings(); if (!A.modelsErr) setTimeout(hideSettings, 700); }, function (err) { setMsg(String(err && err.message || err), true); });
|
||||
}
|
||||
function connect() {
|
||||
return loadModels().catch(function () { /* shown in the form and the dot */ }).then(who);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ window
|
||||
function geom() { var g = A.ui.geom || {}; return { r: g.r === undefined ? 12 : g.r, b: g.b === undefined ? 12 : g.b, w: g.w || 390, h: g.h || 480 }; }
|
||||
function place() {
|
||||
var win = $("agwin");
|
||||
if (win.hidden) return;
|
||||
var c = win.parentNode, foot = c.querySelector(".st-status"), fh = foot ? foot.offsetHeight : 0;
|
||||
var cw = c.clientWidth, ch = c.clientHeight - fh, g = geom();
|
||||
var w = Math.max(280, Math.min(g.w, cw - 8)), h = Math.max(220, Math.min(g.h, ch - 8));
|
||||
var r = Math.max(4, Math.min(g.r, cw - w - 4)), b = Math.max(4, Math.min(g.b, ch - h - 4));
|
||||
win.style.width = w + "px"; win.style.height = h + "px";
|
||||
win.style.right = r + "px"; win.style.bottom = (b + fh) + "px";
|
||||
}
|
||||
function dragger(el, fn) {
|
||||
el.addEventListener("pointerdown", function (e) {
|
||||
if (e.button !== 0 || e.target.closest("button, input, select, a, code")) return;
|
||||
e.preventDefault();
|
||||
var g0 = geom(), x0 = e.clientX, y0 = e.clientY;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
function mv(ev) { A.ui.geom = fn(g0, ev.clientX - x0, ev.clientY - y0); place(); }
|
||||
function up() {
|
||||
el.removeEventListener("pointermove", mv); el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up);
|
||||
var win = $("agwin"), foot = win.parentNode.querySelector(".st-status");
|
||||
A.ui.geom = { w: win.offsetWidth, h: win.offsetHeight, r: parseFloat(win.style.right), b: parseFloat(win.style.bottom) - (foot ? foot.offsetHeight : 0) };
|
||||
saveUi();
|
||||
}
|
||||
el.addEventListener("pointermove", mv); el.addEventListener("pointerup", up); el.addEventListener("pointercancel", up);
|
||||
});
|
||||
}
|
||||
function open(on) {
|
||||
var win = $("agwin");
|
||||
win.hidden = !on;
|
||||
$("agent").setAttribute("aria-pressed", String(on));
|
||||
A.ui.open = on; saveUi();
|
||||
if (!on) return;
|
||||
place();
|
||||
$("agtext").focus();
|
||||
if (!A.started) {
|
||||
A.started = true;
|
||||
ready().then(function () { if (A.modelsErr && !A.msgs.length) fillSettings(); })
|
||||
.catch(function (e) { error("The agent could not start: " + (e && e.message || e)); });
|
||||
}
|
||||
}
|
||||
function buildUI() {
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button"; btn.id = "agent"; btn.className = "ag-open";
|
||||
btn.title = "Chat with an AI agent that edits this scene";
|
||||
btn.setAttribute("aria-pressed", "false");
|
||||
btn.textContent = "✦ Agent";
|
||||
$("preview").parentNode.insertBefore(btn, $("preview"));
|
||||
var win = document.createElement("section");
|
||||
win.className = "ag-win"; win.id = "agwin"; win.hidden = true;
|
||||
win.setAttribute("aria-label", "Agent");
|
||||
win.innerHTML =
|
||||
'<header class="ag-head" id="aghead" title="Drag to move">' +
|
||||
'<b class="ag-logo">✦ Agent</b><button type="button" class="ag-who" id="agwho"></button><span class="st-grow"></span>' +
|
||||
'<button type="button" id="agnew" title="Start a new chat (the scene stays as it is)">New chat</button>' +
|
||||
'<button type="button" id="agset" title="Endpoint, key and model" aria-pressed="false">⚙</button>' +
|
||||
'<button type="button" id="agclose" title="Close (the chat stays)">✕</button></header>' +
|
||||
'<form class="ag-set" id="agsetf" hidden autocomplete="off">' +
|
||||
'<label for="agurl">Endpoint</label><input id="agurl" spellcheck="false" placeholder="https://…/v1">' +
|
||||
'<label for="agkey">API key</label><div class="ag-row"><input id="agkey" type="password" autocomplete="new-password"><button type="button" id="agforget" title="Forget the saved key">Forget</button></div>' +
|
||||
'<label for="agmodel">Model</label><div class="ag-row"><select id="agmodel"></select><button type="button" id="agrefresh" title="Ask the endpoint for its models">↻</button></div>' +
|
||||
'<span></span><label class="ag-check"><input type="checkbox" id="agthink"> Think before answering <small>(slower, plans better)</small></label>' +
|
||||
'<p class="ag-fnote" id="agsetnote"></p>' +
|
||||
'<div class="ag-row ag-end"><span class="ag-smsg" id="agsetmsg"></span><button type="submit">Save</button></div></form>' +
|
||||
'<div class="ag-log" id="aglog" aria-live="polite"></div>' +
|
||||
'<form class="ag-in" id="agin"><textarea id="agtext" rows="2" placeholder="Tell the agent what to change… (Enter sends, Shift+Enter for a new line)"></textarea>' +
|
||||
'<button type="submit" id="agsend" title="Send (Enter)">Send</button></form>' +
|
||||
'<span class="ag-grip" id="aggrip" title="Drag to resize"></span>';
|
||||
document.querySelector(".st-centre").appendChild(win);
|
||||
renderEmpty();
|
||||
|
||||
btn.addEventListener("click", function () { open(win.hidden); });
|
||||
$("agclose").addEventListener("click", function () { open(false); });
|
||||
$("agnew").addEventListener("click", newChat);
|
||||
$("agset").addEventListener("click", function () { if ($("agsetf").hidden) fillSettings(); else hideSettings(); });
|
||||
$("agwho").addEventListener("click", function () { if ($("agsetf").hidden) fillSettings(); else hideSettings(); });
|
||||
$("agsetf").addEventListener("submit", saveSettings);
|
||||
$("agrefresh").addEventListener("click", function () { setMsg("Asking…"); connect().then(fillSettings); });
|
||||
$("agforget").addEventListener("click", function () { saveCfg({ key: "" }).then(connect).then(fillSettings); });
|
||||
$("agin").addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
if (A.busy) { stop(); return; }
|
||||
var t = $("agtext");
|
||||
if (!t.value.trim()) return;
|
||||
send(t.value); t.value = "";
|
||||
});
|
||||
$("agtext").addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (!A.busy) $("agin").requestSubmit(); }
|
||||
if (e.key === "Escape") { if (A.busy) stop(); else e.target.blur(); }
|
||||
});
|
||||
log().addEventListener("click", function (e) {
|
||||
var s = e.target.closest(".ag-sug");
|
||||
if (s) { send(s.textContent); return; }
|
||||
var o = e.target.closest(".ag-obj");
|
||||
if (o) {
|
||||
e.preventDefault();
|
||||
var id = o.getAttribute("data-obj");
|
||||
if (st().obj(id)) { st().select(id); st().view.focus(id, 3); } else st().toast("\u201c" + id + "\u201d is no longer in the scene");
|
||||
return;
|
||||
}
|
||||
var b = e.target.closest("button[data-act]");
|
||||
if (b && b.getAttribute("data-act") === "settings") fillSettings();
|
||||
if (b && b.getAttribute("data-act") === "retry" && !A.busy) {
|
||||
// drop the failed turn's leftovers and ask again
|
||||
while (A.msgs.length && A.msgs[A.msgs.length - 1].role !== "user") A.msgs.pop();
|
||||
var last = A.msgs.pop();
|
||||
b.closest(".ag-err").remove();
|
||||
if (last) send(last.content);
|
||||
}
|
||||
});
|
||||
dragger($("aghead"), function (g, dx, dy) { return { w: g.w, h: g.h, r: g.r - dx, b: g.b - dy }; });
|
||||
dragger($("aggrip"), function (g, dx, dy) { return { w: g.w - dx, h: g.h - dy, r: g.r, b: g.b }; });
|
||||
window.addEventListener("resize", place);
|
||||
if (A.ui.open) open(true);
|
||||
window.__agent = { A: A, send: send, stop: stop, open: open, newChat: newChat };
|
||||
}
|
||||
|
||||
function start() { if (!$("agwin")) buildUI(); }
|
||||
if (window.__studio && window.__studio.ready) start();
|
||||
else window.addEventListener("isokit:studio", start, { once: true });
|
||||
})();
|
||||
+3
-1
@@ -68,11 +68,13 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
<tr><th>Copy / delete</th><td>Ctrl+D / Delete</td></tr>
|
||||
<tr><th>Undo / redo</th><td>Ctrl+Z / Ctrl+Y</td></tr>
|
||||
<tr><th>View</th><td>drag the ground to pan, Ctrl + wheel or pinch to zoom, ↺ ↻ to rotate, levels to look inside</td></tr>
|
||||
<tr><th>Agents</th><td>connect an AI agent over MCP; its edits appear here live (see the panel when nothing is selected)</td></tr>
|
||||
<tr><th>Agent</th><td>✦ Agent opens a chat with a language model that edits the scene for you; Undo takes its steps back. Drag the window by its title, resize it at the top-left corner</td></tr>
|
||||
<tr><th>MCP</th><td>connect an AI agent over MCP; its edits appear here live (see the panel when nothing is selected)</td></tr>
|
||||
</table>
|
||||
<form method="dialog"><button>Close</button></form>
|
||||
</dialog>
|
||||
<script src="/assets/viewer/isokit-view.js"></script>
|
||||
<script src="/assets/studio/studio.js"></script>
|
||||
<script src="/assets/studio/agent.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -83,6 +83,73 @@ input[type=checkbox] { accent-color: var(--accent); }
|
||||
.st-dialog th { text-align: left; padding: 3px 12px 3px 0; color: var(--mute); font-weight: 500; vertical-align: top; }
|
||||
.st-dialog form { text-align: right; margin-top: 10px; }
|
||||
|
||||
/* agent window (agent.js): floats over the map, moves by its title, resizes at the top-left corner */
|
||||
[hidden] { display: none !important; }
|
||||
.ag-open[aria-pressed=true] { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 18%, var(--panel)); }
|
||||
.ag-win { position: absolute; right: 12px; bottom: 40px; width: 400px; height: 540px; z-index: 6; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px; box-shadow: 0 8px 28px rgba(0, 0, 0, .35); overflow: hidden; }
|
||||
.ag-head { display: flex; align-items: center; gap: 6px; padding: 5px 6px 5px 12px; border-bottom: 1px solid var(--stroke); background: var(--panel2); cursor: move; user-select: none; touch-action: none; }
|
||||
.ag-head button { padding: 2px 8px; }
|
||||
.ag-logo { white-space: nowrap; }
|
||||
.ag-logo, .ag-open { color: var(--fg); }
|
||||
.ag-who { background: none; border: 0; padding: 0 2px !important; color: var(--mute); font-size: 12px; overflow: hidden; text-overflow: ellipsis; min-width: 0; display: flex; align-items: center; gap: 5px; }
|
||||
.ag-who:hover { color: var(--fg); }
|
||||
.ag-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--mute); flex: 0 0 auto; }
|
||||
.ag-dot.ag-on { background: var(--accent); }
|
||||
.ag-dot.ag-off { background: var(--danger); }
|
||||
.ag-set { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 5px 8px; align-items: center; padding: 8px 10px; border-bottom: 1px solid var(--stroke); font-size: 12px; }
|
||||
.ag-set > label { color: var(--mute); }
|
||||
.ag-set input:not([type=checkbox]), .ag-set select { width: 100%; }
|
||||
.ag-row { display: flex; gap: 4px; align-items: center; min-width: 0; }
|
||||
.ag-row > input, .ag-row > select { flex: 1 1 auto; }
|
||||
.ag-row button { padding: 2px 8px; }
|
||||
.ag-check { color: var(--fg); display: flex; align-items: center; gap: 5px; }
|
||||
.ag-check small { color: var(--mute); }
|
||||
.ag-fnote { grid-column: 1 / -1; margin: 0; color: var(--mute); }
|
||||
.ag-fnote code { font-size: 11px; word-break: break-all; }
|
||||
.ag-end { grid-column: 1 / -1; justify-content: flex-end; }
|
||||
.ag-smsg { flex: 1 1 auto; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ag-badtext { color: var(--danger); white-space: normal; }
|
||||
.ag-log { flex: 1 1 auto; overflow: auto; padding: 10px 10px 4px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.ag-empty { color: var(--mute); margin: auto 0 0; }
|
||||
.ag-empty p { margin: 0 0 8px; }
|
||||
.ag-sugs { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.ag-sug { white-space: normal; text-align: left; font-size: 12px; padding: 4px 9px; border-radius: 14px; }
|
||||
.ag-user { align-self: flex-end; max-width: 88%; background: color-mix(in srgb, var(--accent) 16%, var(--panel2)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--stroke)); border-radius: 10px 10px 2px 10px; padding: 5px 10px; overflow-wrap: anywhere; }
|
||||
.ag-bot { overflow-wrap: anywhere; }
|
||||
.ag-bot p { margin: 0 0 6px; }
|
||||
.ag-bot p:last-child { margin-bottom: 0; }
|
||||
.ag-bot ul, .ag-bot ol { margin: 0 0 6px; padding-left: 20px; }
|
||||
.ag-bot pre, .ag-tool pre { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 4px; padding: 5px 7px; margin: 4px 0; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow: auto; }
|
||||
.ag-bot code, .ag-tool code { background: var(--panel2); border-radius: 3px; padding: 0 3px; font-size: 12px; }
|
||||
.ag-live .ag-text:empty::after, .ag-live .ag-text > :last-child::after { content: "\258D"; color: var(--accent); animation: ag-blink 1s steps(2) infinite; }
|
||||
.ag-live .ag-think:not([hidden]) + .ag-text:empty::after { content: none; }
|
||||
@keyframes ag-blink { 50% { opacity: 0; } }
|
||||
.ag-think { color: var(--mute); font-size: 12px; margin-bottom: 4px; }
|
||||
.ag-think summary { cursor: pointer; }
|
||||
.ag-live .ag-think summary { animation: ag-pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes ag-pulse { 50% { opacity: .45; } }
|
||||
.ag-think div { white-space: pre-wrap; border-left: 2px solid var(--stroke); padding: 2px 0 2px 8px; margin: 4px 0; max-height: 180px; overflow: auto; }
|
||||
.ag-tool { font-size: 12px; }
|
||||
.ag-tool summary { cursor: pointer; display: flex; align-items: center; gap: 6px; color: var(--mute); list-style: none; padding: 2px 6px; border: 1px solid var(--stroke); border-radius: 5px; background: var(--panel2); }
|
||||
.ag-tool summary::-webkit-details-marker { display: none; }
|
||||
.ag-tool summary::before { content: "\2699"; }
|
||||
.ag-tool summary b { color: var(--fg); font-weight: 600; font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; }
|
||||
.ag-tool summary span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ag-tool summary em { font-style: normal; }
|
||||
.ag-tool.ag-ok summary em { color: var(--accent); }
|
||||
.ag-tool.ag-bad summary { border-color: color-mix(in srgb, var(--danger) 50%, var(--stroke)); }
|
||||
.ag-tool.ag-bad summary em { color: var(--danger); }
|
||||
.ag-obj { cursor: pointer; color: var(--accent); text-decoration: underline dotted; }
|
||||
.ag-note { color: var(--mute); font-size: 12px; font-style: italic; }
|
||||
.ag-err { color: var(--danger); font-size: 12px; border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--stroke)); border-radius: 5px; padding: 5px 8px; overflow-wrap: anywhere; }
|
||||
.ag-link { background: none; border: 0; padding: 0 2px; color: var(--fg); text-decoration: underline; font-size: 12px; }
|
||||
.ag-in { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--stroke); align-items: flex-end; }
|
||||
.ag-in textarea { flex: 1 1 auto; resize: none; min-height: 38px; max-height: 140px; line-height: 1.35; }
|
||||
.ag-in button { align-self: stretch; min-width: 62px; }
|
||||
.ag-in .ag-stop { border-color: var(--danger); color: var(--danger); }
|
||||
.ag-grip { position: absolute; left: 0; top: 0; width: 14px; height: 14px; cursor: nwse-resize; touch-action: none; background: linear-gradient(135deg, var(--mute) 0 2px, transparent 2px 4px, var(--mute) 4px 5px, transparent 5px); opacity: .5; border-top-left-radius: 8px; }
|
||||
.ag-grip:hover { opacity: 1; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.st-main { grid-template-columns: 200px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 40vh; }
|
||||
.st-right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--stroke); }
|
||||
|
||||
+3
-2
@@ -125,7 +125,7 @@
|
||||
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; }
|
||||
}
|
||||
});
|
||||
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast };
|
||||
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true };
|
||||
}
|
||||
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
|
||||
function remount() {
|
||||
@@ -291,7 +291,7 @@
|
||||
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
|
||||
(B && B.agentBox ? B.agentBox(m) :
|
||||
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
|
||||
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live.</p>' +
|
||||
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live. ✦ Agent (top bar) chats with one right here.</p>' +
|
||||
"HTTP (while the Studio runs):<code>" + esc(base) + "/mcp</code>" +
|
||||
"stdio (any time):<code>isokit mcp " + esc(m.dir || ".") + "</code></div>") +
|
||||
'<p class="st-help">Select an object to edit it, or pick one on the left to add it.</p>';
|
||||
@@ -380,5 +380,6 @@
|
||||
showVersion();
|
||||
renderPalette(); renderLists(); mountView(); renderRight();
|
||||
poll();
|
||||
window.dispatchEvent(new Event("isokit:studio")); // agent.js waits for this
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Agent window checks, shared by check_studio.py (isokit studio) and
|
||||
check_web.py (web Studio). The page's agent must use the mock model
|
||||
(tools/mock_llm.py, key test-key); `start_mock()` runs one."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
MOCK_PORT = 7932
|
||||
MOCK = f"http://127.0.0.1:{MOCK_PORT}"
|
||||
|
||||
|
||||
def start_mock():
|
||||
p = subprocess.Popen([sys.executable, str(Path(__file__).with_name("mock_llm.py")), str(MOCK_PORT)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
p.stdout.readline() # the banner: listening
|
||||
return p
|
||||
|
||||
|
||||
def seen():
|
||||
return json.loads(urllib.request.urlopen(MOCK + "/_requests").read())
|
||||
|
||||
|
||||
def chats():
|
||||
return [r for r in seen() if r.get("path") == "/v1/chat/completions"]
|
||||
|
||||
|
||||
def run(pg, objects, shot, check, can_set_key):
|
||||
"""objects(): the scene's objects now. can_set_key: the key is set in the
|
||||
window (web), not by the Studio server's environment."""
|
||||
pg.locator("#agent").click()
|
||||
check(pg.locator("#agwin").is_visible(), "✦ Agent opens the agent window")
|
||||
pg.wait_for_function("() => /mock-1/.test(document.getElementById('agwho').textContent)", timeout=8000)
|
||||
check(pg.locator("#agwho .ag-on").count() == 1, "the window connects to the model: " + pg.locator("#agwho").inner_text())
|
||||
check(pg.locator(".ag-sug").count() >= 3, "an empty chat offers suggestions")
|
||||
shot("agent-empty.jpg")
|
||||
|
||||
# a turn with two tool calls, then an answer
|
||||
n0 = len(objects())
|
||||
pg.locator("#agtext").fill("Plant a tree at 6, 4 please")
|
||||
pg.locator("#agtext").press("Enter")
|
||||
pg.wait_for_selector(".ag-bot:not(.ag-live) .ag-text code.ag-obj", timeout=15000)
|
||||
objs = objects()
|
||||
new = [o for o in objs if o.get("name") == "Agent tree"]
|
||||
check(len(objs) == n0 + 1 and len(new) == 1 and new[0]["x"] == 6 and new[0]["y"] == 4,
|
||||
"the agent's add_object lands in the scene at (6, 4)")
|
||||
tid = new[0]["id"] if new else "?"
|
||||
check(pg.locator(".ag-tool.ag-ok").count() == 2, "both tool calls show as done")
|
||||
check(pg.locator(f'.ag-tool code.ag-obj[data-obj="{tid}"]').count() == 1, "the add_object row links the new object")
|
||||
check(pg.locator(f'#objects li[data-id="{tid}"]').count() == 1, "the object list shows the agent's tree at once")
|
||||
check(pg.locator(f'.iso-svg g[data-obj="{tid}"]').count() >= 1, "the map draws the agent's tree")
|
||||
check(pg.locator(".ag-think summary").first.inner_text().startswith("Thought for"), "the reasoning folds away: "
|
||||
+ pg.locator(".ag-think summary").first.inner_text())
|
||||
txt = pg.locator(".ag-bot .ag-text").last.inner_text()
|
||||
check("Planted" in txt and tid in txt, "the answer streams in: " + txt.replace("\n", " ")[:70])
|
||||
check(pg.locator(".ag-bot .ag-text li").count() == 2 and pg.locator(".ag-bot .ag-text b").count() == 1,
|
||||
"markdown list and bold render")
|
||||
shot("agent-chat.jpg")
|
||||
|
||||
c = chats()
|
||||
first, second = c[-2]["body"], c[-1]["body"]
|
||||
sysmsg = first["messages"][0]
|
||||
names = [t["function"]["name"] for t in first.get("tools", [])]
|
||||
check(all(r["auth"] == "Bearer test-key" for r in c[-2:]), "the key reaches the model")
|
||||
check(sysmsg["role"] == "system" and "isokit Studio" in sysmsg["content"] and "Nothing is selected" in sysmsg["content"]
|
||||
and "project_info" in sysmsg["content"], "the system prompt has the MCP instructions and the Studio state")
|
||||
check(len(names) == 17 and {"add_object", "apply_ops", "select_object", "set_view"} <= set(names),
|
||||
f"the model gets 15 MCP tools + 2 page tools ({len(names)})")
|
||||
check(first.get("stream") is True and first.get("model") == "mock-1", "streamed, first offered model by default")
|
||||
tool_ids = [m.get("tool_call_id") for m in second["messages"] if m["role"] == "tool"]
|
||||
check(tool_ids == ["call_a", "call_b"], f"tool results go back with their call ids {tool_ids}")
|
||||
|
||||
# the id in the answer selects the object
|
||||
pg.locator(f'.ag-bot .ag-text code.ag-obj[data-obj="{tid}"]').click()
|
||||
pg.wait_for_timeout(500)
|
||||
check(("id " + tid) in pg.locator("#right .st-sub").inner_text(), "clicking the id selects the agent's tree")
|
||||
# the selection reaches the model in the next turn; a plain answer
|
||||
pg.locator("#agtext").fill("hello")
|
||||
pg.locator("#agtext").press("Enter")
|
||||
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
|
||||
check(('"id":"%s"' % tid) in chats()[-1]["body"]["messages"][0]["content"], "the system prompt names the selection")
|
||||
|
||||
# Undo takes the agent's edit back
|
||||
pg.locator("#undo").click()
|
||||
pg.wait_for_timeout(700)
|
||||
check(not any(o.get("name") == "Agent tree" for o in objects()), "Undo removes the agent's tree")
|
||||
|
||||
# Stop a long answer
|
||||
pg.locator("#agtext").fill("tell me a slow story")
|
||||
pg.locator("#agtext").press("Enter")
|
||||
pg.wait_for_timeout(900)
|
||||
check(pg.locator("#agsend").inner_text() == "Stop", "while the agent works, Send turns into Stop")
|
||||
pg.locator("#agsend").click()
|
||||
pg.wait_for_selector(".ag-note:has-text('Stopped')", timeout=5000)
|
||||
check(pg.locator("#agsend").inner_text() == "Send" and "word1" in pg.locator(".ag-bot .ag-text").last.inner_text(),
|
||||
"Stop ends the turn and keeps what came")
|
||||
|
||||
# settings: pick another model
|
||||
def settings():
|
||||
pg.wait_for_timeout(900) # a good save folds the form away after a moment
|
||||
if pg.locator("#agsetf").is_hidden():
|
||||
pg.locator("#agset").click()
|
||||
|
||||
def save():
|
||||
pg.locator('#agsetf button[type="submit"]').click()
|
||||
pg.wait_for_timeout(700)
|
||||
|
||||
settings()
|
||||
check(pg.locator("#agsetf").is_visible(), "⚙ shows the settings")
|
||||
opts = pg.locator("#agmodel option").all_inner_texts()
|
||||
check("mock-2" in opts, f"the model list comes from the endpoint {opts}")
|
||||
pg.locator("#agmodel").select_option("mock-2")
|
||||
save()
|
||||
pg.wait_for_function("() => /mock-2/.test(document.getElementById('agwho').textContent)", timeout=5000)
|
||||
pg.locator("#agtext").fill("hello again")
|
||||
pg.locator("#agtext").press("Enter")
|
||||
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
|
||||
check(chats()[-1]["body"]["model"] == "mock-2", "the chosen model is used")
|
||||
if can_set_key:
|
||||
settings()
|
||||
pg.locator("#agkey").fill("wrong-key")
|
||||
save()
|
||||
check("401" in pg.locator("#agsetmsg").inner_text(), "a wrong key shows the endpoint's refusal: " + pg.locator("#agsetmsg").inner_text())
|
||||
check(pg.locator("#agwho .ag-off").count() == 1, "the status dot turns red")
|
||||
shot("agent-badkey.jpg")
|
||||
pg.locator("#agkey").fill("test-key")
|
||||
save()
|
||||
check(pg.locator("#agwho .ag-on").count() == 1, "the right key connects again")
|
||||
else:
|
||||
settings()
|
||||
check(pg.locator("#agurl").is_disabled() and "ISOKIT_LLM_KEY" in pg.locator("#agkey").get_attribute("placeholder"),
|
||||
"endpoint and key set by the environment are shown as such")
|
||||
shot("agent-settings.jpg")
|
||||
settings()
|
||||
pg.locator("#agmodel").select_option("")
|
||||
save()
|
||||
check("mock-1" in pg.locator("#agwho").inner_text(), "back to the first offered model")
|
||||
|
||||
# move and resize the window
|
||||
box = pg.locator("#agwin").bounding_box()
|
||||
hb = pg.locator(".ag-logo").bounding_box()
|
||||
pg.mouse.move(hb["x"] + 5, hb["y"] + 5)
|
||||
pg.mouse.down()
|
||||
pg.mouse.move(hb["x"] - 95, hb["y"] - 35, steps=5)
|
||||
pg.mouse.up()
|
||||
box2 = pg.locator("#agwin").bounding_box()
|
||||
check(abs(box2["x"] - box["x"] + 100) < 3 and abs(box2["y"] - box["y"] + 40) < 3, "the window moves by its title bar")
|
||||
gb = pg.locator("#aggrip").bounding_box()
|
||||
pg.mouse.move(gb["x"] + 4, gb["y"] + 4)
|
||||
pg.mouse.down()
|
||||
pg.mouse.move(gb["x"] - 56, gb["y"] - 26, steps=5)
|
||||
pg.mouse.up()
|
||||
box3 = pg.locator("#agwin").bounding_box()
|
||||
check(abs(box3["width"] - box2["width"] - 60) < 3 and abs(box3["height"] - box2["height"] - 30) < 3, "the top-left grip resizes it")
|
||||
|
||||
pg.locator("#agnew").click()
|
||||
check(pg.locator(".ag-empty").count() == 1 and pg.locator(".ag-tool").count() == 0, "New chat clears the conversation")
|
||||
pg.locator("#agclose").click()
|
||||
check(pg.locator("#agwin").is_hidden() and pg.locator("#agent").get_attribute("aria-pressed") == "false", "✕ closes the window")
|
||||
+11
-2
@@ -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, '<title>isokit Studio</title>',
|
||||
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">'
|
||||
+ (f'\n<meta name="isokit-build" content="{build}">' if build else ""))
|
||||
+ (f'\n<meta name="isokit-build" content="{build}">' if build else "")
|
||||
+ (f'\n<meta name="isokit-agent" content="{html_attr(a.agent_url)}">' if a.agent_url else ""))
|
||||
html = replace_once(html, 'href="/assets/studio/favicon.svg"', f'href="assets/studio/favicon.svg?{v}"')
|
||||
html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"')
|
||||
html = replace_once(html, '<link rel="stylesheet" href="/assets/theme.css">', '<style id="iso-theme"></style>')
|
||||
@@ -158,6 +165,8 @@ def main():
|
||||
f'<link rel="stylesheet" href="assets/studio/studio.css?{v}">\n<link rel="stylesheet" href="assets/web/web.css?{v}">')
|
||||
html = replace_once(html, 'href="/view"', 'href="#"')
|
||||
html = replace_once(html, '<script src="/assets/viewer/isokit-view.js"></script>', f'<script src="assets/viewer/isokit-view.js?{v}"></script>')
|
||||
# agent.js waits for studio.js, which web.js starts once the engine is loaded
|
||||
html = replace_once(html, '<script src="/assets/studio/agent.js"></script>', f'<script src="assets/studio/agent.js?{v}"></script>')
|
||||
html = replace_once(html, '<script src="/assets/studio/studio.js"></script>',
|
||||
f'<script type="module" src="assets/web/web.js?{v}"></script>\n'
|
||||
'<noscript>The isokit Studio needs JavaScript and WebAssembly.</noscript>')
|
||||
|
||||
+19
-1
@@ -1,10 +1,18 @@
|
||||
"""Functional test of the isokit Studio (GUI) and its MCP endpoint, headless Edge."""
|
||||
"""Functional test of the isokit Studio (GUI), its MCP endpoint and the agent
|
||||
window, headless Edge. The agent window runs on a mock model this script starts:
|
||||
|
||||
ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DIR=<scratch> \
|
||||
isokit studio <copy of examples/demo> --port 7890 --no-open
|
||||
uv run --with playwright python tools/check_studio.py <screenshot dir> http://127.0.0.1:7890
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
import agent_checks
|
||||
|
||||
BASE = sys.argv[2] if len(sys.argv) > 2 else "http://127.0.0.1:7878"
|
||||
OUT = Path(sys.argv[1])
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
@@ -36,6 +44,7 @@ def obj(id_):
|
||||
return next((o for o in doc()["objects"] if o["id"] == id_), None)
|
||||
|
||||
|
||||
mock = agent_checks.start_mock()
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(channel="msedge", headless=True)
|
||||
pg = b.new_page(viewport={"width": 1500, "height": 950})
|
||||
@@ -140,8 +149,17 @@ with sync_playwright() as p:
|
||||
pg.locator('[data-a="rotr"]').click()
|
||||
pg.wait_for_timeout(600)
|
||||
pg.screenshot(path=str(OUT / "studio-gf.jpg"), type="jpeg", quality=80)
|
||||
# the agent window, on the mock model (the Studio must run with ISOKIT_LLM_URL=<mock>/v1, see the docstring)
|
||||
cfg = get("/api/agent/config")
|
||||
if agent_checks.MOCK not in cfg["url"]:
|
||||
check(False, "agent window checks need the Studio started with ISOKIT_LLM_URL=" + agent_checks.MOCK + "/v1 (found " + cfg["url"] + ")")
|
||||
else:
|
||||
check(not cfg.get("key") and cfg["has_key"] and cfg["key_from"] == "env", "/api/agent/config says a key is set, without the key")
|
||||
pg.locator('.iso-seg button[data-level="site"]').click()
|
||||
agent_checks.run(pg, lambda: doc()["objects"], lambda n: pg.screenshot(path=str(OUT / n), type="jpeg", quality=80), check, can_set_key=False)
|
||||
check(not errs, f"no script errors {errs}")
|
||||
b.close()
|
||||
|
||||
mock.terminate()
|
||||
print("FAILED:" if fails else "ALL PASSED", fails)
|
||||
sys.exit(1 if fails else 0)
|
||||
+15
-2
@@ -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)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""A stand-in for an OpenAI-compatible model server, for the agent window tests.
|
||||
|
||||
python tools/mock_llm.py [port] (default 7932; base URL http://127.0.0.1:<port>/v1)
|
||||
|
||||
Wants the key `test-key`. Answers like a model that uses tools:
|
||||
- a user message "plant a tree at X, Y" -> reasoning, then two tool calls:
|
||||
list_objects {type: tree} and add_object {type: tree, x: X, y: Y, name: "Agent tree"}
|
||||
- tool results -> a short answer naming the new object's id in backticks
|
||||
- a user message containing "slow" -> a long, slow answer (to test Stop)
|
||||
- anything else -> a plain answer
|
||||
Streams (SSE) when asked to. Every page origin may call it (CORS, also the
|
||||
preflight). GET /_requests lists the requests seen (headers and bodies).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
KEY = "test-key"
|
||||
SEEN = []
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Headers", "authorization, content-type")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
|
||||
def reply(self, code, obj):
|
||||
b = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.cors()
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
def authorized(self):
|
||||
if self.headers.get("Authorization") == "Bearer " + KEY:
|
||||
return True
|
||||
self.reply(401, {"error": {"message": "invalid api key"}})
|
||||
return False
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.cors()
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/_requests":
|
||||
return self.reply(200, SEEN)
|
||||
if self.path == "/v1/models":
|
||||
if self.authorized():
|
||||
self.reply(200, {"object": "list", "data": [{"id": "mock-1", "object": "model"}, {"id": "mock-2", "object": "model"}]})
|
||||
return
|
||||
self.reply(404, {"error": {"message": "not found"}})
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
SEEN.append({"path": self.path, "auth": self.headers.get("Authorization"), "body": body})
|
||||
if self.path != "/v1/chat/completions":
|
||||
return self.reply(404, {"error": {"message": "not found"}})
|
||||
if not self.authorized():
|
||||
return
|
||||
msgs = body.get("messages", [])
|
||||
last = msgs[-1] if msgs else {}
|
||||
deltas, finish = [], "stop"
|
||||
if last.get("role") == "user" and "slow" in last.get("content", ""):
|
||||
deltas = [{"content": "word%d " % i} for i in range(80)]
|
||||
return self.stream(body, deltas, finish, pause=0.1)
|
||||
m = re.search(r"tree at (-?[\d.]+),\s*(-?[\d.]+)", last.get("content", "")) if last.get("role") == "user" else None
|
||||
if m:
|
||||
x, y = float(m.group(1)), float(m.group(2))
|
||||
deltas = [{"reasoning": "The user wants a tree. "}, {"reasoning": "I look at the trees first, then add one."},
|
||||
{"tool_calls": [{"index": 0, "id": "call_a", "type": "function", "function": {"name": "list_objects", "arguments": ""}}]},
|
||||
{"tool_calls": [{"index": 0, "function": {"arguments": '{"type": "tree"}'}}]},
|
||||
{"tool_calls": [{"index": 1, "id": "call_b", "type": "function", "function": {"name": "add_object", "arguments": '{"type": "tree", '}}]},
|
||||
{"tool_calls": [{"index": 1, "function": {"arguments": '"x": %s, "y": %s, "name": "Agent tree"}' % (x, y)}}]}]
|
||||
finish = "tool_calls"
|
||||
elif last.get("role") == "tool":
|
||||
new_id = "?"
|
||||
for mm in reversed(msgs):
|
||||
if mm.get("role") == "tool":
|
||||
try:
|
||||
new_id = json.loads(mm["content"]).get("id") or new_id
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if new_id != "?":
|
||||
break
|
||||
deltas = [{"content": "Planted "}, {"content": "`%s` for you." % new_id}, {"content": "\n\n- one tree\n- **no** overlaps"}]
|
||||
else:
|
||||
deltas = [{"content": "Hello from the mock model."}]
|
||||
self.stream(body, deltas, finish)
|
||||
|
||||
def stream(self, body, deltas, finish, pause=0.02):
|
||||
if not body.get("stream"):
|
||||
msg = {"role": "assistant", "content": "".join(d.get("content", "") for d in deltas)}
|
||||
return self.reply(200, {"choices": [{"index": 0, "message": msg, "finish_reason": finish}]})
|
||||
self.send_response(200)
|
||||
self.cors()
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
for d in [{"role": "assistant", "content": ""}] + deltas:
|
||||
ch = {"object": "chat.completion.chunk", "model": body.get("model") or "mock-1",
|
||||
"choices": [{"index": 0, "delta": d, "finish_reason": None}]}
|
||||
self.wfile.write(("data: %s\n\n" % json.dumps(ch)).encode())
|
||||
self.wfile.flush()
|
||||
time.sleep(pause)
|
||||
end = {"object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {}, "finish_reason": finish}]}
|
||||
self.wfile.write(("data: %s\n\ndata: [DONE]\n\n" % json.dumps(end)).encode())
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
SEEN.append({"aborted": True})
|
||||
self.close_connection = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 7932
|
||||
print(f"mock model on http://127.0.0.1:{port}/v1", flush=True)
|
||||
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
|
||||
+1
-1
@@ -286,7 +286,7 @@ function backend() {
|
||||
agentBox: function () {
|
||||
var dir = isFolder() ? "<folder of " + P.rec.name + ">" : "<project folder>";
|
||||
return '<div class="st-box"><h2>AI agents (MCP)</h2>' +
|
||||
'<p class="st-help">The 15 isokit MCP tools run inside this page: browsers with WebMCP offer them to their assistant, and scripts call <b>isokit.mcp(message)</b>.</p>' +
|
||||
'<p class="st-help">The 15 isokit MCP tools run inside this page: ✦ Agent (top bar) chats with a model that uses them, browsers with WebMCP offer them to their assistant, and scripts call <b>isokit.mcp(message)</b>.</p>' +
|
||||
(isFolder() ? "Desktop agents work on the same folder; their edits show up here live:" : "For desktop agents, open a folder (Projects → Open folder) or export the project, then:") +
|
||||
"<code>isokit mcp " + esc(dir) + "</code></div>";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user