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
+1690
-25
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user