diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 0e6edb6..d115823 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -822,6 +822,7 @@ Mirrored / scaffold areas needing real implementation: - [ ] Token budget calculations ### Tier 3 — Nice-to-Have Features +- [x] Local web GUI (FastAPI + vanilla JS SPA) → `src/gui/__main__.py`, `src/gui/server.py`, `src/gui/static/*`; launch with `python -m src.gui` or `claw-code-gui` - [ ] TUI/Ink component library - [ ] Voice mode - [ ] VIM mode and keybinding system diff --git a/pyproject.toml b/pyproject.toml index ddd4e5e..bfc275d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,11 @@ classifiers = [ "Topic :: Software Development", "Topic :: Software Development :: Build Tools", ] -dependencies = [] +dependencies = [ + "fastapi>=0.110", + "uvicorn>=0.27", + "pydantic>=2.5", +] [project.urls] Homepage = "https://github.com/HarnessLab/claw-code-agent" @@ -40,6 +44,7 @@ Repository = "https://github.com/HarnessLab/claw-code-agent" [project.scripts] claw-code-agent = "src.main:main" +claw-code-gui = "src.gui.__main__:main" [tool.setuptools] include-package-data = true @@ -51,4 +56,5 @@ include = ["src*"] [tool.setuptools.package-data] src = [ "reference_data/*.json", + "gui/static/*", ] diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..9c3a8f3 --- /dev/null +++ b/src/gui/__init__.py @@ -0,0 +1,4 @@ +"""Local web GUI for the claw-code agent. + +Run with: ``python -m src.gui`` or ``python -m src.gui --port 8765``. +""" diff --git a/src/gui/__main__.py b/src/gui/__main__.py new file mode 100644 index 0000000..5840ef0 --- /dev/null +++ b/src/gui/__main__.py @@ -0,0 +1,85 @@ +"""GUI entry point: ``python -m src.gui [--port N] [--cwd PATH] ...``.""" + +from __future__ import annotations + +import argparse +import os +import webbrowser +from pathlib import Path + +import uvicorn + +from ..session_store import DEFAULT_AGENT_SESSION_DIR +from .server import AgentState, create_app + + +def main() -> None: + parser = argparse.ArgumentParser( + prog='claw-code-gui', + description='Launch the local web GUI for the claw-code agent.', + ) + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', type=int, default=8765) + parser.add_argument( + '--cwd', + default='.', + help='Working directory that the agent operates in (default: current dir).', + ) + parser.add_argument( + '--model', + default=os.environ.get('OPENAI_MODEL', 'Qwen/Qwen3-Coder-30B-A3B-Instruct'), + ) + parser.add_argument( + '--base-url', + default=os.environ.get('OPENAI_BASE_URL', 'http://127.0.0.1:8000/v1'), + ) + parser.add_argument( + '--api-key', + default=os.environ.get('OPENAI_API_KEY', 'local-token'), + ) + parser.add_argument( + '--session-dir', + default=str(DEFAULT_AGENT_SESSION_DIR), + help='Directory where agent sessions are saved (default: .port_sessions/agent).', + ) + parser.add_argument('--allow-shell', action='store_true') + parser.add_argument('--allow-write', action='store_true') + parser.add_argument( + '--no-browser', + action='store_true', + help='Do not auto-open a browser tab on launch.', + ) + + args = parser.parse_args() + + state = AgentState( + cwd=Path(args.cwd), + model=args.model, + base_url=args.base_url, + api_key=args.api_key, + allow_shell=args.allow_shell, + allow_write=args.allow_write, + session_directory=Path(args.session_dir), + ) + app = create_app(state) + + url = f'http://{args.host}:{args.port}' + print(f'Claw Code GUI listening on {url}') + print(f' cwd : {state.cwd}') + print(f' model : {state.model}') + print(f' base-url : {state.base_url}') + print(f' sessions : {state.session_directory}') + print(f' shell : {"on" if state.allow_shell else "off"}') + print(f' write : {"on" if state.allow_write else "off"}') + + if not args.no_browser: + try: + webbrowser.open(url) + except Exception: + pass + + uvicorn.run(app, host=args.host, port=args.port, log_level='info') + + +if __name__ == '__main__': + main() diff --git a/src/gui/server.py b/src/gui/server.py new file mode 100644 index 0000000..8894d20 --- /dev/null +++ b/src/gui/server.py @@ -0,0 +1,340 @@ +"""FastAPI server backing the local web GUI. + +Wraps a single global :class:`LocalCodingAgent`, exposes JSON endpoints for +chat, slash commands, and saved sessions, and serves the static SPA. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from threading import Lock +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from ..agent_runtime import LocalCodingAgent +from ..agent_slash_commands import get_slash_command_specs +from ..agent_types import ( + AgentPermissions, + AgentRuntimeConfig, + ModelConfig, +) +from ..bundled_skills import get_bundled_skills +from ..session_store import ( + DEFAULT_AGENT_SESSION_DIR, + StoredAgentSession, + load_agent_session, +) + + +STATIC_DIR = Path(__file__).parent / 'static' + + +# --------------------------------------------------------------------------- +# Agent state holder +# --------------------------------------------------------------------------- + +class AgentState: + """Holds the live agent instance plus a lock for serialized access.""" + + def __init__( + self, + *, + cwd: Path, + model: str, + base_url: str, + api_key: str, + allow_shell: bool, + allow_write: bool, + session_directory: Path, + ) -> None: + self.cwd = cwd.resolve() + self.session_directory = session_directory + self._lock = Lock() + self._agent: LocalCodingAgent | None = None + self.model = model + self.base_url = base_url + self.api_key = api_key + self.allow_shell = allow_shell + self.allow_write = allow_write + self._build_agent() + + def _build_agent(self) -> None: + permissions = AgentPermissions( + allow_file_write=self.allow_write, + allow_shell_commands=self.allow_shell, + ) + runtime_config = AgentRuntimeConfig( + cwd=self.cwd, + permissions=permissions, + session_directory=self.session_directory, + ) + model_config = ModelConfig( + model=self.model, + base_url=self.base_url, + api_key=self.api_key, + ) + self._agent = LocalCodingAgent( + model_config=model_config, + runtime_config=runtime_config, + ) + + @property + def agent(self) -> LocalCodingAgent: + assert self._agent is not None + return self._agent + + def update( + self, + *, + model: str | None = None, + base_url: str | None = None, + api_key: str | None = None, + cwd: str | None = None, + allow_shell: bool | None = None, + allow_write: bool | None = None, + ) -> None: + with self._lock: + if model is not None: + self.model = model + if base_url is not None: + self.base_url = base_url + if api_key is not None: + self.api_key = api_key + if cwd is not None: + resolved = Path(cwd).expanduser().resolve() + if not resolved.is_dir(): + raise ValueError(f'cwd does not exist: {resolved}') + self.cwd = resolved + if allow_shell is not None: + self.allow_shell = allow_shell + if allow_write is not None: + self.allow_write = allow_write + self._build_agent() + + def snapshot(self) -> dict[str, Any]: + return { + 'model': self.model, + 'base_url': self.base_url, + 'cwd': str(self.cwd), + 'session_directory': str(self.session_directory), + 'allow_shell': self.allow_shell, + 'allow_write': self.allow_write, + 'active_session_id': self.agent.active_session_id, + } + + def lock(self) -> Lock: + return self._lock + + +# --------------------------------------------------------------------------- +# Request models +# --------------------------------------------------------------------------- + +class ChatRequest(BaseModel): + prompt: str = Field(min_length=1) + resume_session_id: str | None = None + + +class StateUpdate(BaseModel): + model: str | None = None + base_url: str | None = None + api_key: str | None = None + cwd: str | None = None + allow_shell: bool | None = None + allow_write: bool | None = None + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + +def create_app(state: AgentState) -> FastAPI: + app = FastAPI(title='Claw Code GUI', version='1.0') + + # ------------- static + index ------------------------------------------ + app.mount( + '/static', + StaticFiles(directory=str(STATIC_DIR)), + name='static', + ) + + @app.get('/', include_in_schema=False) + async def root() -> FileResponse: + return FileResponse(STATIC_DIR / 'index.html') + + # ------------- info ------------------------------------------------------ + @app.get('/api/state') + async def get_state() -> dict[str, Any]: + return state.snapshot() + + @app.post('/api/state') + async def post_state(payload: StateUpdate) -> dict[str, Any]: + try: + state.update(**payload.model_dump(exclude_none=True)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return state.snapshot() + + @app.get('/api/slash-commands') + async def list_slash_commands() -> list[dict[str, Any]]: + commands: list[dict[str, Any]] = [] + for spec in get_slash_command_specs(): + commands.append( + { + 'names': list(spec.names), + 'primary': spec.names[0], + 'description': spec.description, + } + ) + return commands + + @app.get('/api/skills') + async def list_skills() -> list[dict[str, Any]]: + return [ + { + 'name': skill.name, + 'description': skill.description, + 'when_to_use': skill.when_to_use, + 'aliases': list(skill.aliases), + 'allowed_tools': list(skill.allowed_tools), + } + for skill in get_bundled_skills() + if skill.user_invocable + ] + + # ------------- sessions -------------------------------------------------- + @app.get('/api/sessions') + async def list_sessions() -> list[dict[str, Any]]: + directory = state.session_directory + if not directory.exists(): + return [] + results: list[dict[str, Any]] = [] + for path in sorted( + directory.glob('*.json'), + key=lambda p: p.stat().st_mtime, + reverse=True, + ): + try: + data = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + continue + messages = data.get('messages') or [] + preview = '' + for msg in messages: + if isinstance(msg, dict) and msg.get('role') == 'user': + content = msg.get('content', '') + if isinstance(content, str): + preview = content[:120] + break + results.append( + { + 'session_id': data.get('session_id', path.stem), + 'turns': data.get('turns', 0), + 'tool_calls': data.get('tool_calls', 0), + 'preview': preview, + 'modified_at': path.stat().st_mtime, + } + ) + return results + + @app.get('/api/sessions/{session_id}') + async def get_session(session_id: str) -> dict[str, Any]: + try: + stored = load_agent_session(session_id, directory=state.session_directory) + except FileNotFoundError: + raise HTTPException(status_code=404, detail='Session not found') + return _serialize_stored_session(stored) + + # ------------- chat ------------------------------------------------------ + @app.post('/api/chat') + async def chat(request: ChatRequest) -> dict[str, Any]: + prompt = request.prompt.strip() + if not prompt: + raise HTTPException(status_code=400, detail='Prompt is empty') + + def _run() -> dict[str, Any]: + with state.lock(): + agent = state.agent + if request.resume_session_id is not None: + try: + stored = load_agent_session( + request.resume_session_id, + directory=state.session_directory, + ) + except FileNotFoundError: + raise HTTPException( + status_code=404, + detail='Session to resume not found', + ) + result = agent.resume(prompt, stored) + else: + result = agent.run(prompt) + return _serialize_run_result(result) + + try: + payload = await asyncio.to_thread(_run) + except HTTPException: + raise + except Exception as exc: # surface the error in the UI + return JSONResponse( + status_code=500, + content={ + 'error': str(exc), + 'error_type': type(exc).__name__, + }, + ) + return payload + + @app.post('/api/clear') + async def clear_state() -> dict[str, Any]: + with state.lock(): + state.agent.clear_runtime_state() + return state.snapshot() + + return app + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + +def _serialize_run_result(result: Any) -> dict[str, Any]: + return { + 'final_output': result.final_output, + 'turns': result.turns, + 'tool_calls': result.tool_calls, + 'transcript': [_normalize_transcript_entry(entry) for entry in result.transcript], + 'session_id': result.session_id, + 'usage': result.usage.to_dict(), + 'total_cost_usd': result.total_cost_usd, + 'stop_reason': result.stop_reason, + } + + +def _normalize_transcript_entry(entry: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = { + 'role': entry.get('role', ''), + 'content': entry.get('content', ''), + } + for key in ('name', 'tool_call_id', 'tool_calls', 'metadata', 'message_id'): + if key in entry and entry[key] not in (None, '', [], {}): + out[key] = entry[key] + return out + + +def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]: + return { + 'session_id': stored.session_id, + 'turns': stored.turns, + 'tool_calls': stored.tool_calls, + 'messages': [_normalize_transcript_entry(dict(m)) for m in stored.messages], + 'usage': stored.usage, + 'total_cost_usd': stored.total_cost_usd, + 'model': stored.model_config.get('model'), + } diff --git a/src/gui/static/app.css b/src/gui/static/app.css new file mode 100644 index 0000000..bbda422 --- /dev/null +++ b/src/gui/static/app.css @@ -0,0 +1,719 @@ +:root { + --bg: #0e0f13; + --bg-elev: #15171d; + --bg-soft: #1c1f27; + --border: #2a2e3a; + --text: #e6e8ee; + --text-dim: #9aa0ad; + --text-muted: #6c7280; + --accent: #ff9333; + --accent-soft: #ff933322; + --user: #5e7cff; + --user-soft: #5e7cff22; + --tool: #58c79a; + --tool-soft: #58c79a18; + --error: #ff6b7a; + --error-soft: #ff6b7a22; + --code-bg: #0a0b0f; + --radius: 10px; + --radius-sm: 6px; + --shadow: 0 8px 30px rgba(0, 0, 0, 0.35); + --font-ui: + "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-mono: + "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Monaco, Consolas, + "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +#app { + display: grid; + grid-template-columns: 280px 1fr; + height: 100vh; + overflow: hidden; +} + +/* ---------------- Sidebar ---------------- */ +.sidebar { + background: var(--bg-elev); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-head { + padding: 16px; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 12px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.brand-mark { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--accent), #c2580f); + display: grid; + place-items: center; + font-size: 11px; + font-weight: 700; + color: #1a0f00; +} + +.sidebar-section { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + overflow-y: auto; +} + +.sidebar-section:last-child { + flex: 0 0 auto; +} + +.sidebar > .sidebar-section:nth-child(2) { + flex: 1 1 auto; +} + +.sidebar-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); + margin-bottom: 8px; +} + +.session-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.session-item { + background: transparent; + border: 1px solid transparent; + color: var(--text-dim); + text-align: left; + cursor: pointer; + padding: 8px 10px; + border-radius: var(--radius-sm); + font: inherit; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 2px; + transition: background 0.12s ease, color 0.12s ease; +} + +.session-item:hover { + background: var(--bg-soft); + color: var(--text); +} + +.session-item.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--text); +} + +.session-item .session-preview { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-item .session-meta { + font-size: 11px; + color: var(--text-muted); +} + +.empty-state { + font-size: 12px; + color: var(--text-muted); + padding: 6px 0; +} + +.settings { + display: flex; + flex-direction: column; + gap: 10px; +} + +.settings label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--text-dim); +} + +.settings input[type="text"] { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + padding: 7px 9px; + border-radius: var(--radius-sm); + font: inherit; + font-size: 12px; +} + +.settings input[type="text"]:focus { + outline: none; + border-color: var(--accent); +} + +.toggle { + flex-direction: row !important; + align-items: center; + gap: 8px !important; + font-size: 12px; +} + +.toggle input { + margin: 0; + accent-color: var(--accent); +} + +/* ---------------- Buttons ---------------- */ +.btn-primary, +.btn-secondary, +.btn-ghost, +.btn-send { + cursor: pointer; + font: inherit; + border-radius: var(--radius-sm); + padding: 8px 12px; + border: 1px solid transparent; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.btn-primary { + background: var(--accent); + color: #1a0f00; + font-weight: 600; +} + +.btn-primary:hover { + background: #ffa64d; +} + +.btn-secondary { + background: var(--bg-soft); + color: var(--text); + border-color: var(--border); +} + +.btn-secondary:hover { + border-color: var(--accent); +} + +.btn-ghost { + background: transparent; + color: var(--text-dim); + border-color: transparent; + padding: 6px 10px; + font-size: 14px; + width: 32px; + height: 32px; + display: inline-grid; + place-items: center; +} + +.btn-ghost:hover { + background: var(--bg-soft); + color: var(--text); +} + +.btn-send { + background: var(--accent); + color: #1a0f00; + font-weight: 600; + align-self: flex-end; + padding: 9px 18px; + height: 36px; +} + +.btn-send:hover:not(:disabled) { + background: #ffa64d; +} + +.btn-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ---------------- Main pane ---------------- */ +.main { + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.topbar { + height: 48px; + border-bottom: 1px solid var(--border); + background: var(--bg); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + flex: 0 0 auto; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-dim); +} + +.topbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--tool); + box-shadow: 0 0 0 3px rgba(88, 199, 154, 0.15); +} + +.status-dot.busy { + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + animation: pulse 1.2s ease-in-out infinite; +} + +.status-dot.error { + background: var(--error); + box-shadow: 0 0 0 3px var(--error-soft); +} + +@keyframes pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.25); + } +} + +/* ---------------- Chat ---------------- */ +.chat { + flex: 1 1 auto; + overflow-y: auto; + padding: 24px 0; + scroll-behavior: smooth; +} + +.welcome { + max-width: 780px; + margin: 60px auto; + padding: 0 24px; + text-align: center; + color: var(--text-dim); +} + +.welcome h1 { + font-size: 32px; + margin-bottom: 12px; + background: linear-gradient(135deg, var(--accent), #ffd089); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.welcome code { + background: var(--bg-soft); + padding: 2px 6px; + border-radius: 4px; + color: var(--accent); +} + +.message { + max-width: 820px; + margin: 0 auto 18px; + padding: 0 24px; + display: flex; + gap: 12px; +} + +.message .avatar { + width: 32px; + height: 32px; + border-radius: 50%; + flex: 0 0 auto; + display: grid; + place-items: center; + font-size: 12px; + font-weight: 700; + margin-top: 2px; +} + +.message.user .avatar { + background: var(--user-soft); + color: var(--user); + border: 1px solid var(--user); +} + +.message.assistant .avatar { + background: var(--accent-soft); + color: var(--accent); + border: 1px solid var(--accent); +} + +.message.tool .avatar { + background: var(--tool-soft); + color: var(--tool); + border: 1px solid var(--tool); + font-size: 14px; +} + +.message.error .avatar { + background: var(--error-soft); + color: var(--error); + border: 1px solid var(--error); +} + +.message .body { + flex: 1; + min-width: 0; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + overflow-wrap: anywhere; +} + +.message.user .body { + background: var(--bg-soft); + border-color: var(--user); +} + +.message.error .body { + border-color: var(--error); + background: var(--error-soft); +} + +.message .body :first-child { + margin-top: 0; +} + +.message .body :last-child { + margin-bottom: 0; +} + +.message .body p { + margin: 8px 0; +} + +.message .body h1, +.message .body h2, +.message .body h3 { + margin: 14px 0 8px; +} + +.message .body ul, +.message .body ol { + padding-left: 22px; + margin: 8px 0; +} + +.message .body code { + font-family: var(--font-mono); + background: var(--code-bg); + padding: 1px 5px; + border-radius: 3px; + font-size: 12.5px; + color: #ffcd91; +} + +.message .body pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px; + overflow-x: auto; + margin: 10px 0; +} + +.message .body pre code { + background: none; + padding: 0; + color: var(--text); + font-size: 12.5px; + line-height: 1.5; +} + +.message .body a { + color: var(--accent); +} + +/* Tool call card */ +.tool-call { + background: var(--bg-elev); + border: 1px solid var(--border); + border-left: 3px solid var(--tool); + border-radius: var(--radius-sm); + margin: 6px 0; + font-family: var(--font-mono); + font-size: 12px; +} + +.tool-call summary { + cursor: pointer; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + list-style: none; + user-select: none; + color: var(--text-dim); +} + +.tool-call summary::-webkit-details-marker { + display: none; +} + +.tool-call summary::before { + content: "▶"; + font-size: 9px; + color: var(--text-muted); + transition: transform 0.15s ease; +} + +.tool-call[open] summary::before { + transform: rotate(90deg); +} + +.tool-call .tool-name { + color: var(--tool); + font-weight: 600; +} + +.tool-call .tool-args { + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.tool-call .tool-body { + border-top: 1px solid var(--border); + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.tool-call .tool-body pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px; + margin: 0; + font-size: 11.5px; + overflow-x: auto; + max-height: 240px; + overflow-y: auto; +} + +.tool-call .label { + color: var(--text-muted); + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Composer */ +.composer { + border-top: 1px solid var(--border); + background: var(--bg); + padding: 12px 16px 14px; + flex: 0 0 auto; +} + +.composer-inner { + max-width: 820px; + margin: 0 auto; + display: flex; + gap: 10px; + align-items: flex-end; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 8px 8px 14px; + transition: border-color 0.12s ease; +} + +.composer-inner:focus-within { + border-color: var(--accent); +} + +#prompt-input { + flex: 1; + background: transparent; + border: none; + resize: none; + color: var(--text); + font: inherit; + font-size: 14px; + line-height: 1.5; + padding: 6px 0; + outline: none; + min-height: 22px; + max-height: 200px; +} + +.composer-meta { + max-width: 820px; + margin: 6px auto 0; + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-muted); +} + +/* Palette */ +.palette { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: grid; + place-items: start center; + padding-top: 12vh; + z-index: 50; +} + +.palette.hidden { + display: none; +} + +.palette-card { + width: min(560px, 92vw); + max-height: 70vh; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.palette-head { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} + +.palette-head input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text); + font: inherit; + font-size: 14px; +} + +.palette-list { + overflow-y: auto; + padding: 6px; +} + +.palette-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + padding: 9px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text); + font: inherit; +} + +.palette-item:hover, +.palette-item.active { + background: var(--bg-soft); +} + +.palette-item .palette-name { + color: var(--accent); + font-family: var(--font-mono); + font-size: 13px; + margin-right: 8px; +} + +.palette-item .palette-desc { + color: var(--text-dim); + font-size: 12px; +} + +/* Scrollbars */ +::-webkit-scrollbar { + width: 9px; + height: 9px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #3b414f; +} + +/* Mobile-ish narrow screens */ +@media (max-width: 720px) { + #app { + grid-template-columns: 1fr; + } + .sidebar { + display: none; + } +} diff --git a/src/gui/static/app.js b/src/gui/static/app.js new file mode 100644 index 0000000..115c664 --- /dev/null +++ b/src/gui/static/app.js @@ -0,0 +1,570 @@ +"use strict"; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +const State = { + serverState: null, + sessions: [], + slashCommands: [], + skills: [], + activeSessionId: null, // the session we will resume on next send + isBusy: false, + paletteMode: null, // "slash" | "skills" | null +}; + +// --------------------------------------------------------------------------- +// DOM refs +// --------------------------------------------------------------------------- +const $ = (sel) => document.querySelector(sel); +const els = { + chat: $("#chat"), + welcome: $("#welcome"), + input: $("#prompt-input"), + sendBtn: $("#send-btn"), + sessionList: $("#session-list"), + newSessionBtn: $("#new-session-btn"), + settingsForm: $("#settings-form"), + statusDot: $("#status-dot"), + statusText: $("#status-text"), + cwdMeta: $("#cwd-meta"), + usageMeta: $("#usage-meta"), + slashBtn: $("#slash-btn"), + skillsBtn: $("#skills-btn"), + clearBtn: $("#clear-btn"), + palette: $("#palette"), + paletteSearch: $("#palette-search"), + paletteList: $("#palette-list"), + paletteClose: $("#palette-close"), +}; + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- +async function apiGet(path) { + const r = await fetch(path); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); + return r.json(); +} + +async function apiPost(path, body) { + const r = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body == null ? "{}" : JSON.stringify(body), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok && !data.error) { + throw new Error(data.detail || `${r.status} ${r.statusText}`); + } + return data; +} + +// --------------------------------------------------------------------------- +// Markdown rendering (small, dependency-free) +// --------------------------------------------------------------------------- +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function renderMarkdown(md) { + if (md == null) return ""; + // Pull fenced code blocks out first so their contents are not parsed. + const codeBlocks = []; + let text = String(md).replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_, lang, body) => { + const idx = codeBlocks.length; + codeBlocks.push({ lang, body }); + return `\u0000CODE${idx}\u0000`; + }); + text = escapeHtml(text); + // Headings + text = text.replace(/^### (.+)$/gm, "

$1

"); + text = text.replace(/^## (.+)$/gm, "

$1

"); + text = text.replace(/^# (.+)$/gm, "

$1

"); + // Bold / italic + text = text.replace(/\*\*([^*\n]+)\*\*/g, "$1"); + text = text.replace(/(^|\W)\*([^*\n]+)\*(\W|$)/g, "$1$2$3"); + // Inline code + text = text.replace(/`([^`\n]+)`/g, "$1"); + // Links + text = text.replace( + /\[([^\]]+)\]\(([^)\s]+)\)/g, + '$1' + ); + // Lists (simple, line-based) + text = text.replace(/(?:^|\n)((?:- .+\n?)+)/g, (_, block) => { + const items = block + .trim() + .split(/\n/) + .map((l) => `
  • ${l.replace(/^- /, "")}
  • `) + .join(""); + return `\n\n`; + }); + text = text.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (_, block) => { + const items = block + .trim() + .split(/\n/) + .map((l) => `
  • ${l.replace(/^\d+\. /, "")}
  • `) + .join(""); + return `\n
      ${items}
    \n`; + }); + // Paragraphs (split on blank lines) + text = text + .split(/\n{2,}/) + .map((chunk) => { + const t = chunk.trim(); + if (!t) return ""; + if (/^<(h\d|ul|ol|pre|blockquote)/.test(t)) return t; + return `

    ${t.replace(/\n/g, "
    ")}

    `; + }) + .join("\n"); + // Restore code blocks + text = text.replace(/\u0000CODE(\d+)\u0000/g, (_, idx) => { + const { lang, body } = codeBlocks[Number(idx)]; + const langClass = lang ? ` class="lang-${escapeHtml(lang)}"` : ""; + return `
    ${escapeHtml(body)}
    `; + }); + return text; +} + +// --------------------------------------------------------------------------- +// UI helpers +// --------------------------------------------------------------------------- +function setStatus(state, text) { + els.statusDot.classList.remove("busy", "error"); + if (state === "busy") els.statusDot.classList.add("busy"); + if (state === "error") els.statusDot.classList.add("error"); + els.statusText.textContent = text; +} + +function setBusy(busy) { + State.isBusy = busy; + els.sendBtn.disabled = busy; + els.input.disabled = busy; + if (busy) setStatus("busy", "Working…"); + else setStatus("ready", "Ready"); +} + +function clearChat() { + els.chat.innerHTML = ""; + if (els.welcome) { + els.chat.appendChild(els.welcome); + } +} + +function hideWelcome() { + if (els.welcome && els.welcome.parentElement) { + els.welcome.remove(); + } +} + +function avatarFor(role) { + if (role === "user") return "U"; + if (role === "assistant") return "AI"; + if (role === "tool") return "⚙"; + if (role === "error") return "!"; + return "•"; +} + +function appendMessage({ role, content, html }) { + hideWelcome(); + const wrap = document.createElement("div"); + wrap.className = `message ${role}`; + + const avatar = document.createElement("div"); + avatar.className = "avatar"; + avatar.textContent = avatarFor(role); + + const body = document.createElement("div"); + body.className = "body"; + if (html != null) { + body.innerHTML = html; + } else if (role === "user") { + body.textContent = content; + } else { + body.innerHTML = renderMarkdown(content); + } + wrap.appendChild(avatar); + wrap.appendChild(body); + els.chat.appendChild(wrap); + els.chat.scrollTop = els.chat.scrollHeight; + return wrap; +} + +function appendToolCall({ name, args, result, isError }) { + hideWelcome(); + const wrap = document.createElement("div"); + wrap.className = "message tool"; + wrap.innerHTML = `
    `; + + const body = document.createElement("div"); + body.className = "body"; + + const details = document.createElement("details"); + details.className = "tool-call"; + + const summary = document.createElement("summary"); + const argsPreview = typeof args === "string" ? args : JSON.stringify(args); + summary.innerHTML = ` + ${escapeHtml(name)} + ${escapeHtml(argsPreview).slice(0, 240)} + `; + details.appendChild(summary); + + const inner = document.createElement("div"); + inner.className = "tool-body"; + inner.innerHTML = ` +
    Arguments
    +
    ${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}
    +
    Result${isError ? " (error)" : ""}
    +
    ${escapeHtml(result || "")}
    + `; + details.appendChild(inner); + body.appendChild(details); + + wrap.appendChild(body); + els.chat.appendChild(wrap); + els.chat.scrollTop = els.chat.scrollHeight; +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- +function applyServerState(state) { + State.serverState = state; + els.settingsForm.model.value = state.model || ""; + els.settingsForm.base_url.value = state.base_url || ""; + els.settingsForm.cwd.value = state.cwd || ""; + els.settingsForm.allow_shell.checked = !!state.allow_shell; + els.settingsForm.allow_write.checked = !!state.allow_write; + els.cwdMeta.textContent = `cwd: ${state.cwd || "?"}`; +} + +async function loadServerState() { + try { + const state = await apiGet("/api/state"); + applyServerState(state); + } catch (e) { + setStatus("error", `state: ${e.message}`); + } +} + +async function saveSettings(ev) { + ev.preventDefault(); + const fd = new FormData(els.settingsForm); + const payload = { + model: fd.get("model") || "", + base_url: fd.get("base_url") || "", + cwd: fd.get("cwd") || "", + allow_shell: els.settingsForm.allow_shell.checked, + allow_write: els.settingsForm.allow_write.checked, + }; + try { + setStatus("busy", "Saving settings…"); + const state = await apiPost("/api/state", payload); + applyServerState(state); + setStatus("ready", "Settings saved"); + } catch (e) { + setStatus("error", `save: ${e.message}`); + } +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- +async function loadSessions() { + try { + const sessions = await apiGet("/api/sessions"); + State.sessions = sessions; + renderSessions(); + } catch (e) { + els.sessionList.innerHTML = `
    ${escapeHtml(e.message)}
    `; + } +} + +function renderSessions() { + els.sessionList.innerHTML = ""; + if (!State.sessions.length) { + els.sessionList.innerHTML = `
    No saved sessions yet.
    `; + return; + } + for (const s of State.sessions) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "session-item"; + if (s.session_id === State.activeSessionId) item.classList.add("active"); + const preview = s.preview || "(no preview)"; + item.innerHTML = ` +
    ${escapeHtml(preview)}
    +
    ${s.turns} turns · ${s.tool_calls} tools
    + `; + item.addEventListener("click", () => openSession(s.session_id)); + els.sessionList.appendChild(item); + } +} + +async function openSession(sessionId) { + try { + setStatus("busy", "Loading session…"); + const session = await apiGet(`/api/sessions/${encodeURIComponent(sessionId)}`); + State.activeSessionId = sessionId; + clearChat(); + for (const msg of session.messages) { + renderTranscriptEntry(msg); + } + renderSessions(); + setStatus("ready", `Session ${sessionId.slice(0, 8)}…`); + } catch (e) { + setStatus("error", `session: ${e.message}`); + } +} + +function newSession() { + State.activeSessionId = null; + clearChat(); + renderSessions(); + setStatus("ready", "New chat"); + els.input.focus(); +} + +// --------------------------------------------------------------------------- +// Transcript rendering +// --------------------------------------------------------------------------- +function renderTranscriptEntry(entry) { + if (!entry || !entry.role) return; + if (entry.role === "system") return; + if (entry.role === "user") { + appendMessage({ role: "user", content: entry.content || "" }); + return; + } + if (entry.role === "assistant") { + if (entry.content && entry.content.trim()) { + appendMessage({ role: "assistant", content: entry.content }); + } + if (Array.isArray(entry.tool_calls)) { + for (const tc of entry.tool_calls) { + const name = tc?.function?.name || tc?.name || "tool"; + let args = tc?.function?.arguments ?? tc?.arguments ?? ""; + try { + if (typeof args === "string") args = JSON.parse(args); + } catch {} + appendToolCall({ name, args, result: "(pending — see following tool message)" }); + } + } + return; + } + if (entry.role === "tool") { + const name = entry.name || "tool"; + const meta = entry.metadata || {}; + appendToolCall({ + name, + args: meta.action || "", + result: entry.content || "", + isError: meta.ok === false, + }); + return; + } +} + +// --------------------------------------------------------------------------- +// Slash commands / skills palette +// --------------------------------------------------------------------------- +async function loadSlashCommands() { + try { + State.slashCommands = await apiGet("/api/slash-commands"); + } catch (e) { + State.slashCommands = []; + } +} + +async function loadSkills() { + try { + State.skills = await apiGet("/api/skills"); + } catch (e) { + State.skills = []; + } +} + +function openPalette(mode) { + State.paletteMode = mode; + els.palette.classList.remove("hidden"); + els.paletteSearch.value = ""; + renderPalette(""); + els.paletteSearch.focus(); +} + +function closePalette() { + els.palette.classList.add("hidden"); + State.paletteMode = null; +} + +function renderPalette(filter) { + const items = State.paletteMode === "skills" ? State.skills : State.slashCommands; + const f = (filter || "").toLowerCase().trim(); + els.paletteList.innerHTML = ""; + for (const item of items) { + const display = + State.paletteMode === "skills" + ? { name: item.name, desc: item.description } + : { name: `/${item.primary}`, desc: item.description }; + if (f && !display.name.toLowerCase().includes(f) && !display.desc.toLowerCase().includes(f)) + continue; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "palette-item"; + btn.innerHTML = `${escapeHtml(display.name)}${escapeHtml(display.desc || "")}`; + btn.addEventListener("click", () => { + if (State.paletteMode === "skills") { + els.input.value = `Use the ${item.name} skill`; + } else { + els.input.value = `/${item.primary} `; + } + closePalette(); + autoSizeInput(); + els.input.focus(); + }); + els.paletteList.appendChild(btn); + } + if (!els.paletteList.children.length) { + els.paletteList.innerHTML = `
    No matches.
    `; + } +} + +// --------------------------------------------------------------------------- +// Composer +// --------------------------------------------------------------------------- +function autoSizeInput() { + els.input.style.height = "auto"; + els.input.style.height = Math.min(els.input.scrollHeight, 200) + "px"; +} + +async function send() { + if (State.isBusy) return; + const prompt = els.input.value.trim(); + if (!prompt) return; + appendMessage({ role: "user", content: prompt }); + els.input.value = ""; + autoSizeInput(); + setBusy(true); + try { + const payload = { + prompt, + resume_session_id: State.activeSessionId || null, + }; + const data = await apiPost("/api/chat", payload); + if (data.error) { + appendMessage({ role: "error", content: `${data.error_type}: ${data.error}` }); + setStatus("error", "Error"); + } else { + // Render tool calls + final response from the transcript so the user + // sees what the agent actually did, not just the final reply. + renderRunResult(data); + State.activeSessionId = data.session_id || State.activeSessionId; + els.usageMeta.textContent = + `turns: ${data.turns} · tools: ${data.tool_calls}` + + (data.usage?.total_tokens + ? ` · tokens: ${data.usage.total_tokens}` + : ""); + await loadSessions(); + } + } catch (e) { + appendMessage({ role: "error", content: e.message }); + setStatus("error", "Error"); + } finally { + setBusy(false); + els.input.focus(); + } +} + +function renderRunResult(data) { + // We already rendered the user prompt. The transcript starts with system + + // user + assistant turns; we want to show: any tool messages that happened + // since the last user message, then the final assistant response. + const transcript = data.transcript || []; + let lastUserIndex = -1; + for (let i = transcript.length - 1; i >= 0; i--) { + if (transcript[i].role === "user") { + lastUserIndex = i; + break; + } + } + const tail = transcript.slice(lastUserIndex + 1); + for (const entry of tail) { + if (entry.role === "assistant") { + // Only render if it has visible content; tool_calls already rendered. + if (Array.isArray(entry.tool_calls) && entry.tool_calls.length) { + // Skip — corresponding tool messages will follow. + continue; + } + } + renderTranscriptEntry(entry); + } + // Always show the canonical final output if it isn't already present. + const lastMsg = els.chat.querySelector(".message.assistant:last-of-type .body"); + if (!lastMsg || lastMsg.textContent.trim() !== (data.final_output || "").trim()) { + if (data.final_output && data.final_output.trim()) { + appendMessage({ role: "assistant", content: data.final_output }); + } + } +} + +// --------------------------------------------------------------------------- +// Wire up +// --------------------------------------------------------------------------- +function bind() { + els.input.addEventListener("input", autoSizeInput); + els.input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }); + els.sendBtn.addEventListener("click", send); + els.newSessionBtn.addEventListener("click", newSession); + els.settingsForm.addEventListener("submit", saveSettings); + els.slashBtn.addEventListener("click", () => openPalette("slash")); + els.skillsBtn.addEventListener("click", () => openPalette("skills")); + els.clearBtn.addEventListener("click", async () => { + try { + setStatus("busy", "Clearing…"); + await apiPost("/api/clear", {}); + newSession(); + setStatus("ready", "Cleared"); + } catch (e) { + setStatus("error", e.message); + } + }); + els.paletteClose.addEventListener("click", closePalette); + els.palette.addEventListener("click", (e) => { + if (e.target === els.palette) closePalette(); + }); + els.paletteSearch.addEventListener("input", (e) => renderPalette(e.target.value)); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !els.palette.classList.contains("hidden")) { + closePalette(); + } + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + openPalette("slash"); + } + }); +} + +async function init() { + bind(); + setStatus("ready", "Ready"); + await Promise.all([ + loadServerState(), + loadSessions(), + loadSlashCommands(), + loadSkills(), + ]); + els.input.focus(); +} + +init(); diff --git a/src/gui/static/index.html b/src/gui/static/index.html new file mode 100644 index 0000000..d85ff78 --- /dev/null +++ b/src/gui/static/index.html @@ -0,0 +1,108 @@ + + + + + + Claw Code + + + +
    + + +
    +
    +
    + + Ready +
    +
    + + + +
    +
    + +
    +
    +

    Claw Code

    +

    Type a message below to start. Use the / button for slash commands or for skills.

    +
    +
    + + +
    +
    + + + + + + diff --git a/tests/test_gui_server.py b/tests/test_gui_server.py new file mode 100644 index 0000000..e52bd89 --- /dev/null +++ b/tests/test_gui_server.py @@ -0,0 +1,144 @@ +"""Integration tests for the local web GUI FastAPI server. + +These tests exercise the JSON endpoints against a real :class:`AgentState` +without booting uvicorn, using ``fastapi.testclient.TestClient``. Slash +commands are dispatched locally inside :class:`LocalCodingAgent` and never +hit the network, so the chat endpoint can be exercised end-to-end against +``/help``. +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + +from src.gui.server import AgentState, create_app + + +def _build_client(tmp: Path) -> tuple[TestClient, AgentState]: + state = AgentState( + cwd=tmp, + model='test-model', + base_url='http://127.0.0.1:8000/v1', + api_key='local-token', + allow_shell=False, + allow_write=False, + session_directory=tmp / 'sessions', + ) + return TestClient(create_app(state)), state + + +class GuiServerTests(unittest.TestCase): + def test_root_serves_html(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.get('/') + self.assertEqual(response.status_code, 200) + self.assertIn('Claw Code', response.text) + + def test_static_assets_served(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + self.assertEqual(client.get('/static/app.css').status_code, 200) + self.assertEqual(client.get('/static/app.js').status_code, 200) + + def test_state_snapshot_round_trip(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.get('/api/state') + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload['model'], 'test-model') + self.assertFalse(payload['allow_shell']) + + updated = client.post( + '/api/state', + json={'allow_shell': True, 'model': 'other-model'}, + ) + self.assertEqual(updated.status_code, 200) + data = updated.json() + self.assertTrue(data['allow_shell']) + self.assertEqual(data['model'], 'other-model') + + def test_state_update_rejects_missing_cwd(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.post( + '/api/state', + json={'cwd': str(Path(d) / 'does-not-exist')}, + ) + self.assertEqual(response.status_code, 400) + + def test_slash_commands_listed(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.get('/api/slash-commands') + self.assertEqual(response.status_code, 200) + commands = response.json() + self.assertTrue(commands) + primaries = {entry['primary'] for entry in commands} + self.assertIn('help', primaries) + + def test_skills_listed(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.get('/api/skills') + self.assertEqual(response.status_code, 200) + skills = response.json() + self.assertTrue(skills) + names = {entry['name'] for entry in skills} + self.assertIn('simplify', names) + + def test_chat_runs_local_slash_command(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.post('/api/chat', json={'prompt': '/help'}) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload['turns'], 0) + self.assertEqual(payload['tool_calls'], 0) + self.assertIn('slash commands', payload['final_output'].lower()) + self.assertIn('/help', payload['final_output']) + self.assertIn('total_tokens', payload['usage']) + + def test_chat_rejects_blank_prompt(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.post('/api/chat', json={'prompt': ' '}) + self.assertEqual(response.status_code, 400) + + def test_chat_resume_unknown_session_returns_404(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.post( + '/api/chat', + json={'prompt': '/help', 'resume_session_id': 'missing'}, + ) + self.assertEqual(response.status_code, 404) + + def test_sessions_list_empty_when_directory_absent(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.get('/api/sessions') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_session_detail_404_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.get('/api/sessions/nope') + self.assertEqual(response.status_code, 404) + + def test_clear_runtime_state(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, _ = _build_client(Path(d)) + response = client.post('/api/clear') + self.assertEqual(response.status_code, 200) + self.assertIn('model', response.json()) + + +if __name__ == '__main__': + unittest.main()