/` | 单个请求对应的 run 根目录。 |
+| `agent_workspace/` | agent 唯一可见的 workspace;文件工具、Bash、`ls`、`cat` 都从这里开始。 |
+| `agent_workspace/inputs/images/` | API 请求中用户提交的图片。 |
+| `agent_trace/` | API trace、agent trace 和 runtime 记录。 |
+
+对于多模态请求,每张图片会同时走两条路径:当底层模型支持多模态输入时,
+图片内容会作为初始多模态输入直接传给模型;每张图片也会保存到
+`agent_workspace/inputs/images/`。每个保存后的相对路径也会写进 agent 可见文本,
+让后续轮次可以用 `ReadImage` 读取稳定的本地路径,而不是反复依赖内联图片字节。
+
+这个结构把 agent 可见工作目录和服务端记录目录隔离开。
+在 API 部署模式下,trace 默认保存:每个请求都会在自己的 `agent_trace/`
+目录下写入 `api_trace.jsonl`、`trace_*.jsonl` 和 `_session_state.json`。
+
+## 6. 纯文本 OpenAI SDK 请求
+
+```python
+from openai import OpenAI
+
+client = OpenAI(api_key="unused", base_url="http://127.0.0.1:8686/v1")
+
+response = client.chat.completions.create(
+ model="researchharness",
+ messages=[
+ {"role": "user", "content": "Answer in one sentence: what is 2 + 2?"}
+ ],
+)
+
+print(response.choices[0].message.content)
+```
+
+## 7. 多模态 OpenAI SDK 请求
+
+第一版 API 支持同一个请求中包含一张或多张 `data:image/...;base64,...` 形式的图片 URL。API server 不支持远程图片 URL,也不支持让外部请求直接传本地文件路径。
+
+下面的示例在代码中生成一张图片,并要求返回 JSON。
+
+```python
+import base64
+from io import BytesIO
+
+from PIL import Image, ImageDraw
+from openai import OpenAI
+
+image = Image.new("RGB", (320, 120), "white")
+draw = ImageDraw.Draw(image)
+draw.text((40, 45), "7 + 5 = ?", fill="black")
+buffer = BytesIO()
+image.save(buffer, format="PNG")
+data_url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
+
+client = OpenAI(api_key="unused", base_url="http://127.0.0.1:8686/v1")
+
+response = client.chat.completions.create(
+ model="researchharness",
+ messages=[
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": (
+ "The image contains a simple arithmetic expression. "
+ "Return JSON with exactly two keys: expression and answer."
+ ),
+ },
+ {"type": "image_url", "image_url": {"url": data_url}},
+ ],
+ }
+ ],
+)
+
+print(response.choices[0].message.content)
+```
+
+预期答案形状:
+
+```json
+{"expression":"7 + 5","answer":12}
+```
+
+## 8. API 请求与返回协议
+
+### `POST /v1/chat/completions`
+
+支持的请求字段:
+
+| 字段 | 是否必需 | 含义 |
+| --- | --- | --- |
+| `model` | 是 | 客户端看到的 model label;不会覆盖 `.env` 中的 `MODEL_NAME`。 |
+| `messages` | 是 | OpenAI-style chat messages。 |
+| `stream` | 否 | 必须不存在或为 `false`;当前不支持 streaming。 |
+| `n` | 否 | 必须不存在或为 `1`。 |
+| `max_tokens` | 否 | output wrapper 最大输出 token。 |
+| `max_completion_tokens` | 否 | output wrapper 最大输出 token 的兼容别名。 |
+| `response_format` | 否 | 作为输出格式提示传给 wrapper。 |
+
+支持的 message role:
+
+| Role | 是否支持 |
+| --- | --- |
+| `system` | 支持 |
+| `user` | 支持 |
+| `assistant` | 支持 |
+| `tool` | 不支持 |
+
+支持的 content 形式:
+
+```json
+{"role": "user", "content": "plain text"}
+```
+
+```json
+{
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "question"},
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
+ ]
+}
+```
+
+返回结构:
+
+```json
+{
+ "id": "chatcmpl_...",
+ "object": "chat.completion",
+ "created": 1770000000,
+ "model": "researchharness",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "final answer"
+ },
+ "finish_reason": "stop"
+ }
+ ]
+}
+```
+
+调用方通常只需要读取:
+
+```python
+response.choices[0].message.content
+```
+
+### `GET /v1/health`
+
+返回:
+
+```json
+{
+ "status": "ok",
+ "api_runs_dir": "./api_runs",
+ "input_wrapper": true,
+ "output_wrapper": true
+}
+```
+
+## 9. 工具能力
+
+ResearchHarness 当前包含:
+
+| 工具 | 用途 |
+| --- | --- |
+| `Glob` | 按模式发现文件。 |
+| `Grep` | 在文件中搜索文本。 |
+| `Read` | 有边界地读取文本文件。 |
+| `ReadPDF` | 通过 MinerU/structai 解析 PDF。 |
+| `ReadImage` | 读取本地图片,并把图片内容传给支持 vision 的模型。 |
+| `Write` | 在 workspace 内写文件。 |
+| `Edit` | 在 workspace 内 patch 文件。 |
+| `Bash` | 在 workspace 内执行 shell 命令。 |
+| `WebSearch` | 通过 Serper 进行网页搜索。 |
+| `ScholarSearch` | 通过 Serper 进行学术搜索。 |
+| `WebFetch` | 通过 Jina 和配置模型抓取、总结网页。 |
+| `AskUser` | 交互式运行中向用户提问;某些 benchmark adapter 会禁用。 |
+| `TerminalStart` / `TerminalWrite` / `TerminalRead` / `TerminalInterrupt` / `TerminalKill` | 持久终端会话。 |
+
+## 10. Trace 与记录
+
+CLI 运行只有在传入 `--trace-dir` 时才会写 trace。如果不传
+`--trace-dir`,CLI 运行不会写 trace 文件。
+
+API 运行时,记录在:
+
+```text
+./api_runs/run_.../agent_trace/
+```
+
+重要文件:
+
+| 文件 | 含义 |
+| --- | --- |
+| `api_trace.jsonl` | input wrapper、agent result、output wrapper 记录。 |
+| `trace_*.jsonl` | agent runtime 的 flat trace。 |
+| `_session_state.json` | 当前 session state;启用 trace 时和 `trace_*.jsonl` 写在同一目录。 |
+
+trace 会记录工具调用、工具结果、LLM call capture payload、context compaction、错误和终止状态。
+
+## 11. Benchmark Adapter
+
+tracked benchmark contract 放在 `benchmarks/` 下。
+
+当前 tracked adapter:
+
+| Benchmark | 目录 | 说明 |
+| --- | --- | --- |
+| ResearchClawBench | `benchmarks/ResearchClawBench/` | CLI 方式接入,包含 role prompt 和 adapter。 |
+| QA / VQA | `benchmarks/QA/` | OpenAI-compatible API 方式接入,支持纯文本和多模态 QA。 |
+
+benchmark-specific 行为应放在 `benchmarks/`,不要塞进 `agent_base/`。
+
+## 12. 测试
+
+推荐检查:
+
+```bash
+python3 tests/test_tool_availability.py
+python3 tests/test_openai_api_checks.py
+python3 tests/test_agent_extension_checks.py
+python3 tests/test_edge_case_checks.py
+python3 tests/test_toolchain_validation.py
+```
+
+如果使用 conda:
+
+```bash
+/home/xwh/miniconda3/bin/conda run -n agent python3 tests/test_openai_api_checks.py
+```
+
+## 13. 排障
+
+常见问题:
+
+| 现象 | 可能原因 | 处理 |
+| --- | --- | --- |
+| 缺少 required env | `.env` 不完整 | 填写所有必需变量。 |
+| Web/PDF 工具失败 | VPN/proxy/TLS/服务问题 | 关闭 VPN/proxy 后重跑工具可用性测试。 |
+| 图片请求返回 400 | 图片不是 `data:image/...;base64,...` | 把图片转成 base64 data URL。 |
+| 后端模型拒绝图片 | 当前模型 endpoint 不支持 vision | 换用支持 vision 的模型,或改为纯文本任务。 |
+| API 报 streaming 错误 | 请求里传了 `stream=true` | 当前只支持同步请求。 |
+| 输出格式不符合预期 | output wrapper 关闭,或用户格式要求不明确 | 开启 `--output-wrapper`,并清楚说明输出格式。 |
+
+## 14. 当前边界
+
+第一版 API 暂不包括:
+
+- streaming,
+- async run status,
+- cancellation,
+- artifact download endpoint,
+- 远程图片 URL 下载,
+- 用户认证,
+- 多租户访问控制。
+
+这些能力以后可以作为外层服务继续扩展,不需要破坏核心 harness loop。
diff --git a/frontend/__init__.py b/frontend/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..93d8c148c8dca018e271146995be1180644e7cf9
--- /dev/null
+++ b/frontend/__init__.py
@@ -0,0 +1 @@
+"""Local browser UI for ResearchHarness."""
diff --git a/frontend/local_server.py b/frontend/local_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..39cf727dd559dca6721be143a3171faaa7d14eb1
--- /dev/null
+++ b/frontend/local_server.py
@@ -0,0 +1,578 @@
+from __future__ import annotations
+
+import asyncio
+import base64
+import datetime as _dt
+import os
+import re
+import shutil
+import threading
+import time
+import traceback
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi.responses import FileResponse, JSONResponse
+from fastapi.staticfiles import StaticFiles
+
+from agent_base.react_agent import MultiTurnReactAgent, default_llm_config
+from agent_base.utils import (
+ MissingRequiredEnvError,
+ PROJECT_ROOT,
+ append_saved_image_paths_to_prompt,
+ image_input_content_parts,
+ load_dotenv,
+ require_required_env,
+ safe_jsonable,
+ stage_image_bytes_for_input,
+)
+
+
+STATIC_DIR = Path(__file__).resolve().parent / "static"
+MAX_UPLOAD_IMAGES = 12
+MAX_IMAGE_BYTES = 12 * 1024 * 1024
+MAX_DIRECTORY_ENTRIES = 800
+FRONTEND_ROLE_PROMPT = ""
+FRONTEND_TRACE_DIR: str | None = None
+FRONTEND_MANAGED_RUNS_DIR: str | None = None
+FRONTEND_CLEANUP_RETENTION_SECONDS = 6 * 60 * 60
+FRONTEND_CLEANUP_MAX_RUNS = 40
+FRONTEND_CLEANUP_INTERVAL_SECONDS = 15 * 60
+_CLEANUP_THREAD_STARTED = False
+_ACTIVE_MANAGED_RUNS: set[str] = set()
+_ACTIVE_MANAGED_RUNS_LOCK = threading.Lock()
+
+app = FastAPI(title="ResearchHarness Local UI")
+app.mount("/static", StaticFiles(directory=STATIC_DIR), name="frontend-static")
+
+
+def configure_frontend(
+ *,
+ role_prompt: str = "",
+ trace_dir: str | None = None,
+ managed_runs_dir: str | None = None,
+ cleanup_retention_seconds: int | None = None,
+ cleanup_max_runs: int | None = None,
+ cleanup_interval_seconds: int | None = None,
+) -> None:
+ global FRONTEND_ROLE_PROMPT, FRONTEND_TRACE_DIR, FRONTEND_MANAGED_RUNS_DIR
+ global FRONTEND_CLEANUP_RETENTION_SECONDS, FRONTEND_CLEANUP_MAX_RUNS, FRONTEND_CLEANUP_INTERVAL_SECONDS
+ FRONTEND_ROLE_PROMPT = str(role_prompt or "").strip()
+ if trace_dir:
+ path = Path(trace_dir).expanduser()
+ if path.exists() and not path.is_dir():
+ raise ValueError(f"trace-dir is not a directory: {path}")
+ path.mkdir(parents=True, exist_ok=True)
+ FRONTEND_TRACE_DIR = str(path)
+ else:
+ FRONTEND_TRACE_DIR = None
+
+ if managed_runs_dir:
+ path = Path(managed_runs_dir).expanduser()
+ if path.exists() and not path.is_dir():
+ raise ValueError(f"managed-runs-dir is not a directory: {path}")
+ path.mkdir(parents=True, exist_ok=True)
+ FRONTEND_MANAGED_RUNS_DIR = str(path)
+ if cleanup_retention_seconds is not None:
+ FRONTEND_CLEANUP_RETENTION_SECONDS = max(60, int(cleanup_retention_seconds))
+ if cleanup_max_runs is not None:
+ FRONTEND_CLEANUP_MAX_RUNS = max(1, int(cleanup_max_runs))
+ if cleanup_interval_seconds is not None:
+ FRONTEND_CLEANUP_INTERVAL_SECONDS = max(60, int(cleanup_interval_seconds))
+ cleanup_managed_runs_once()
+ _start_managed_cleanup_thread()
+ else:
+ FRONTEND_MANAGED_RUNS_DIR = None
+
+
+class FrontendRunBridge:
+ def __init__(self, *, loop: asyncio.AbstractEventLoop):
+ self.loop = loop
+ self.outbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
+ self.cancelled = threading.Event()
+ self.conversation_messages: list[dict[str, Any]] | None = None
+ self.conversation_workspace_root: str = ""
+ self.managed_run_root: str = ""
+ self.managed_workspace_root: str = ""
+ self.managed_trace_dir: str = ""
+ self._pending_answers: dict[str, str] = {}
+ self._pending_events: dict[str, threading.Event] = {}
+ self._lock = threading.Lock()
+
+ def send(self, payload: dict[str, Any]) -> None:
+ self.loop.call_soon_threadsafe(self.outbound.put_nowait, safe_jsonable(payload))
+
+ def trace_event(self, row: dict[str, Any]) -> None:
+ self.send({"type": "trace", "row": row})
+
+ def submit_answer(self, request_id: str, answer: str) -> bool:
+ with self._lock:
+ event = self._pending_events.get(request_id)
+ if event is None:
+ return False
+ self._pending_answers[request_id] = str(answer)
+ event.set()
+ return True
+
+ def ask_user(self, *, question: str, context: str = "") -> str:
+ request_id = uuid4().hex
+ event = threading.Event()
+ with self._lock:
+ self._pending_events[request_id] = event
+ self.send(
+ {
+ "type": "ask_user",
+ "request_id": request_id,
+ "question": question,
+ "context": context,
+ }
+ )
+ while not event.wait(0.2):
+ if self.cancelled.is_set():
+ return "[AskUser] Cancelled before user answer was received."
+ with self._lock:
+ answer = self._pending_answers.pop(request_id, "")
+ self._pending_events.pop(request_id, None)
+ answer = str(answer).strip()
+ if not answer:
+ return "[AskUser] User answer was empty."
+ return f"[AskUser] User answer:\n{answer}"
+
+
+def _managed_runs_root() -> Path | None:
+ if not FRONTEND_MANAGED_RUNS_DIR:
+ return None
+ return Path(FRONTEND_MANAGED_RUNS_DIR).expanduser().resolve()
+
+
+def _new_managed_run_root() -> Path:
+ root = _managed_runs_root()
+ if root is None:
+ raise ValueError("managed workspace mode is not configured")
+ timestamp = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
+ return root / f"run_{timestamp}_{uuid4().hex[:8]}"
+
+
+def _mark_managed_run_active(run_root: Path) -> None:
+ with _ACTIVE_MANAGED_RUNS_LOCK:
+ _ACTIVE_MANAGED_RUNS.add(str(run_root.resolve()))
+
+
+def _release_managed_run(bridge: FrontendRunBridge) -> None:
+ if bridge.managed_run_root:
+ with _ACTIVE_MANAGED_RUNS_LOCK:
+ _ACTIVE_MANAGED_RUNS.discard(str(Path(bridge.managed_run_root).resolve()))
+ bridge.managed_run_root = ""
+ bridge.managed_workspace_root = ""
+ bridge.managed_trace_dir = ""
+
+
+def _create_managed_run(bridge: FrontendRunBridge) -> tuple[Path, str]:
+ run_root = _new_managed_run_root()
+ workspace_root = run_root / "agent_workspace"
+ trace_dir = run_root / "agent_trace"
+ workspace_root.mkdir(parents=True, exist_ok=True)
+ trace_dir.mkdir(parents=True, exist_ok=True)
+ bridge.managed_run_root = str(run_root)
+ bridge.managed_workspace_root = str(workspace_root)
+ bridge.managed_trace_dir = str(trace_dir)
+ _mark_managed_run_active(run_root)
+ return workspace_root, str(trace_dir)
+
+
+def cleanup_managed_runs_once() -> None:
+ root = _managed_runs_root()
+ if root is None or not root.exists():
+ return
+ now = time.time()
+ with _ACTIVE_MANAGED_RUNS_LOCK:
+ active = set(_ACTIVE_MANAGED_RUNS)
+ runs = []
+ for child in root.iterdir():
+ if not child.is_dir() or not child.name.startswith("run_"):
+ continue
+ try:
+ resolved = str(child.resolve())
+ mtime = child.stat().st_mtime
+ except OSError:
+ continue
+ runs.append((mtime, child, resolved))
+
+ for mtime, child, resolved in runs:
+ if resolved in active:
+ continue
+ if FRONTEND_CLEANUP_RETENTION_SECONDS and now - mtime > FRONTEND_CLEANUP_RETENTION_SECONDS:
+ shutil.rmtree(child, ignore_errors=True)
+
+ remaining = []
+ with _ACTIVE_MANAGED_RUNS_LOCK:
+ active = set(_ACTIVE_MANAGED_RUNS)
+ for child in root.iterdir():
+ if not child.is_dir() or not child.name.startswith("run_"):
+ continue
+ try:
+ remaining.append((child.stat().st_mtime, child, str(child.resolve())))
+ except OSError:
+ continue
+ remaining.sort(reverse=True, key=lambda item: item[0])
+ for _, child, resolved in remaining[FRONTEND_CLEANUP_MAX_RUNS:]:
+ if resolved not in active:
+ shutil.rmtree(child, ignore_errors=True)
+
+
+def _managed_cleanup_loop() -> None:
+ while True:
+ time.sleep(FRONTEND_CLEANUP_INTERVAL_SECONDS)
+ cleanup_managed_runs_once()
+
+
+def _start_managed_cleanup_thread() -> None:
+ global _CLEANUP_THREAD_STARTED
+ if _CLEANUP_THREAD_STARTED:
+ return
+ thread = threading.Thread(target=_managed_cleanup_loop, daemon=True)
+ thread.start()
+ _CLEANUP_THREAD_STARTED = True
+
+
+class FrontendInteractiveAgent(MultiTurnReactAgent):
+ def __init__(self, *, bridge: FrontendRunBridge, **kwargs: Any):
+ super().__init__(**kwargs)
+ self.bridge = bridge
+
+ def custom_call_tool(self, tool_name: str, tool_args: Any, **kwargs: Any):
+ if tool_name != "AskUser":
+ return super().custom_call_tool(tool_name, tool_args, **kwargs)
+ tool = self.tool_map.get("AskUser")
+ if tool is None:
+ return "[AskUser] Tool is not available in this run."
+ try:
+ parsed = tool.parse_json_args(tool_args)
+ except ValueError as exc:
+ return f"[AskUser] {exc}"
+ question = str(parsed.get("question", "")).strip()
+ context = str(parsed.get("context", "") or "").strip()
+ if not question:
+ return "[AskUser] question must be a non-empty string."
+ return self.bridge.ask_user(question=question, context=context)
+
+
+def _safe_image_suffix(mime: str, filename: str = "") -> str:
+ suffix = Path(filename).suffix.lower()
+ if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}:
+ return suffix
+ mapping = {
+ "image/png": ".png",
+ "image/jpeg": ".jpg",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+ }
+ return mapping.get(mime.lower(), ".png")
+
+
+def decode_image_data_url(data_url: str, *, filename: str = "") -> tuple[str, bytes]:
+ match = re.fullmatch(r"data:(image/[A-Za-z0-9.+-]+);base64,(.*)", str(data_url), flags=re.DOTALL)
+ if not match:
+ raise ValueError("image must be a data:image/...;base64,... URL")
+ mime = match.group(1)
+ try:
+ raw = base64.b64decode(match.group(2), validate=True)
+ except ValueError as exc:
+ raise ValueError(f"invalid base64 image data: {exc}") from exc
+ if not raw:
+ raise ValueError("image upload is empty")
+ if len(raw) > MAX_IMAGE_BYTES:
+ raise ValueError(f"image upload exceeds {MAX_IMAGE_BYTES} bytes")
+ return _safe_image_suffix(mime, filename), raw
+
+
+def save_uploaded_images(workspace_root: Path, images: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]:
+ if len(images) > MAX_UPLOAD_IMAGES:
+ raise ValueError(f"at most {MAX_UPLOAD_IMAGES} images are supported per run")
+ if not images:
+ return [], []
+ timestamp = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
+ content_parts: list[dict[str, Any]] = []
+ saved_paths: list[str] = []
+ for idx, item in enumerate(images, start=1):
+ if not isinstance(item, dict):
+ raise ValueError("each image item must be an object")
+ data_url = str(item.get("data_url", "")).strip()
+ filename = str(item.get("name", "") or f"image_{idx}")
+ suffix, raw = decode_image_data_url(data_url, filename=filename)
+ saved_path = stage_image_bytes_for_input(
+ raw,
+ workspace_root=workspace_root,
+ filename=f"{timestamp}_{filename}",
+ image_index=idx - 1,
+ suffix=suffix,
+ )
+ saved_paths.append(saved_path)
+ content_parts.extend(image_input_content_parts(data_url, saved_path))
+ return content_parts, saved_paths
+
+
+def _prompt_with_uploaded_image_paths(prompt: str, saved_paths: list[str]) -> str:
+ return append_saved_image_paths_to_prompt(prompt, saved_paths)
+
+
+def _run_agent_thread(
+ *,
+ bridge: FrontendRunBridge,
+ prompt: str,
+ workspace_root: Path,
+ initial_content_parts: list[dict[str, Any]],
+ trace_dir: str | None = None,
+ prior_messages: list[dict[str, Any]] | None = None,
+) -> None:
+ try:
+ load_dotenv(PROJECT_ROOT / ".env")
+ require_required_env("ResearchHarness frontend")
+ effective_trace_dir = trace_dir if trace_dir is not None else FRONTEND_TRACE_DIR
+ agent = FrontendInteractiveAgent(
+ bridge=bridge,
+ llm=default_llm_config(),
+ trace_dir=effective_trace_dir,
+ role_prompt=FRONTEND_ROLE_PROMPT or None,
+ )
+ bridge.send(
+ {
+ "type": "run_started",
+ "model": agent.model,
+ "workspace_root": str(workspace_root),
+ "trace_dir": effective_trace_dir or "",
+ }
+ )
+ result = agent._run_session(
+ prompt,
+ workspace_root=str(workspace_root),
+ event_callback=bridge.trace_event,
+ initial_content_parts=initial_content_parts or None,
+ prior_messages=prior_messages,
+ interrupt_event=bridge.cancelled,
+ )
+ bridge.conversation_messages = result.get("messages", [])
+ bridge.conversation_workspace_root = str(workspace_root)
+ bridge.send(
+ {
+ "type": "run_finished",
+ "result_text": result.get("result_text", ""),
+ "termination": result.get("termination", ""),
+ }
+ )
+ except (MissingRequiredEnvError, ValueError) as exc:
+ bridge.send({"type": "run_error", "error": str(exc)})
+ except Exception as exc:
+ bridge.send({"type": "run_error", "error": str(exc), "traceback": traceback.format_exc()})
+
+
+def _resolve_existing_workspace(raw_path: str) -> Path:
+ if not str(raw_path or "").strip():
+ raise ValueError("workspace path is required")
+ path = Path(raw_path).expanduser()
+ if not path.is_absolute():
+ path = (Path.cwd() / path).resolve()
+ else:
+ path = path.resolve()
+ if not path.exists() or not path.is_dir():
+ raise ValueError(f"workspace must be an existing directory: {path}")
+ return path
+
+
+def _resolve_directory_browser_path(raw_path: str = "") -> Path:
+ text = str(raw_path or "").strip()
+ if text:
+ path = Path(text).expanduser()
+ else:
+ path = Path.home() if Path.home().exists() else PROJECT_ROOT
+ if not path.is_absolute():
+ path = (Path.cwd() / path).resolve()
+ else:
+ path = path.resolve()
+ if not path.exists() or not path.is_dir():
+ raise ValueError(f"directory does not exist: {path}")
+ return path
+
+
+def _directory_root_choices() -> list[dict[str, str]]:
+ candidates = [Path.home(), PROJECT_ROOT, PROJECT_ROOT / "workspace", Path.cwd(), Path("/mnt"), Path("/")]
+ if os.name == "nt":
+ for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
+ candidates.append(Path(f"{letter}:\\"))
+
+ seen: set[str] = set()
+ roots: list[dict[str, str]] = []
+ for candidate in candidates:
+ try:
+ resolved = candidate.expanduser().resolve()
+ except (OSError, RuntimeError):
+ continue
+ if not resolved.exists() or not resolved.is_dir():
+ continue
+ key = str(resolved)
+ if key in seen:
+ continue
+ seen.add(key)
+ label = "Home" if resolved == Path.home().resolve() else (resolved.name or key)
+ roots.append({"label": label, "path": key})
+ return roots
+
+
+def _workspace_directory_payload(raw_path: str = "") -> dict[str, Any]:
+ directory = _resolve_directory_browser_path(raw_path)
+ entries: list[dict[str, str]] = []
+ truncated = False
+ try:
+ children = sorted(directory.iterdir(), key=lambda item: item.name.casefold())
+ except PermissionError as exc:
+ raise ValueError(f"permission denied: {directory}") from exc
+ except OSError as exc:
+ raise ValueError(f"cannot read directory {directory}: {exc}") from exc
+
+ for child in children:
+ if len(entries) >= MAX_DIRECTORY_ENTRIES:
+ truncated = True
+ break
+ try:
+ if not child.is_dir():
+ continue
+ except OSError:
+ continue
+ entries.append({"name": child.name or str(child), "path": str(child)})
+
+ parent = directory.parent if directory.parent != directory else None
+ return {
+ "path": str(directory),
+ "parent": str(parent) if parent else "",
+ "entries": entries,
+ "truncated": truncated,
+ "roots": _directory_root_choices(),
+ }
+
+
+@app.get("/api/workspace-directories")
+def workspace_directories(path: str = "") -> JSONResponse:
+ try:
+ return JSONResponse(_workspace_directory_payload(path))
+ except ValueError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+
+
+@app.get("/")
+def index() -> FileResponse:
+ return FileResponse(STATIC_DIR / "index.html")
+
+
+@app.get("/favicon.ico")
+def favicon() -> FileResponse:
+ return FileResponse(STATIC_DIR / "favicon.svg", media_type="image/svg+xml")
+
+
+@app.websocket("/ws")
+async def websocket_endpoint(websocket: WebSocket) -> None:
+ await websocket.accept()
+ bridge = FrontendRunBridge(loop=asyncio.get_running_loop())
+ run_thread: threading.Thread | None = None
+
+ async def sender() -> None:
+ while True:
+ payload = await bridge.outbound.get()
+ await websocket.send_json(payload)
+
+ sender_task = asyncio.create_task(sender())
+ try:
+ await websocket.send_json({"type": "ready", "managed_workspace": bool(FRONTEND_MANAGED_RUNS_DIR)})
+ while True:
+ message = await websocket.receive_json()
+ message_type = str(message.get("type", "")).strip()
+ if message_type == "start":
+ if run_thread is not None and run_thread.is_alive():
+ bridge.send({"type": "run_error", "error": "A run is already active. Wait for it to finish before starting a new conversation."})
+ continue
+ prompt = str(message.get("prompt", "")).strip()
+ if not prompt:
+ bridge.send({"type": "run_error", "error": "Prompt is required."})
+ continue
+ try:
+ continue_conversation = bool(message.get("continue_conversation"))
+ prior_messages = None
+ effective_trace_dir = FRONTEND_TRACE_DIR
+ if FRONTEND_MANAGED_RUNS_DIR:
+ if continue_conversation:
+ if not bridge.conversation_messages or not bridge.managed_workspace_root:
+ bridge.send({"type": "run_error", "error": "No active conversation is available on the server. Click New chat and start again."})
+ continue
+ workspace_root = Path(bridge.managed_workspace_root)
+ effective_trace_dir = bridge.managed_trace_dir or FRONTEND_TRACE_DIR
+ prior_messages = bridge.conversation_messages
+ else:
+ _release_managed_run(bridge)
+ workspace_root, effective_trace_dir = _create_managed_run(bridge)
+ else:
+ workspace_root = _resolve_existing_workspace(str(message.get("workspace_root", "")))
+ if continue_conversation:
+ if not bridge.conversation_messages:
+ bridge.send({"type": "run_error", "error": "No active conversation is available on the server. Click New chat and start again."})
+ continue
+ elif bridge.conversation_workspace_root and bridge.conversation_workspace_root != str(workspace_root):
+ bridge.send({"type": "run_error", "error": "Workspace changed. Start a new chat before using a different workspace."})
+ continue
+ else:
+ prior_messages = bridge.conversation_messages
+ image_parts, saved_paths = save_uploaded_images(
+ workspace_root,
+ message.get("images", []) if isinstance(message.get("images", []), list) else [],
+ )
+ run_prompt = _prompt_with_uploaded_image_paths(prompt, saved_paths)
+ except ValueError as exc:
+ bridge.send({"type": "run_error", "error": str(exc)})
+ continue
+ bridge.cancelled.clear()
+ if not continue_conversation:
+ bridge.conversation_messages = None
+ bridge.conversation_workspace_root = str(workspace_root)
+ bridge.send({"type": "conversation_reset"})
+ if saved_paths:
+ bridge.send({"type": "uploaded_images", "paths": saved_paths})
+ run_thread = threading.Thread(
+ target=_run_agent_thread,
+ kwargs={
+ "bridge": bridge,
+ "prompt": run_prompt,
+ "workspace_root": workspace_root,
+ "initial_content_parts": image_parts,
+ "trace_dir": effective_trace_dir,
+ "prior_messages": prior_messages,
+ },
+ daemon=True,
+ )
+ run_thread.start()
+ elif message_type == "ask_user_answer":
+ ok = bridge.submit_answer(str(message.get("request_id", "")), str(message.get("answer", "")))
+ if not ok:
+ bridge.send({"type": "run_error", "error": "No pending AskUser request matched that answer."})
+ elif message_type == "interrupt":
+ if run_thread is not None and run_thread.is_alive():
+ bridge.cancelled.set()
+ bridge.send({"type": "interrupt_requested"})
+ else:
+ bridge.send({"type": "run_error", "error": "No active run is available to interrupt."})
+ elif message_type == "new":
+ if run_thread is not None and run_thread.is_alive():
+ bridge.send({"type": "run_error", "error": "The current run is still active. Start a new conversation after it finishes."})
+ else:
+ _release_managed_run(bridge)
+ bridge.conversation_messages = None
+ bridge.conversation_workspace_root = ""
+ bridge.send({"type": "conversation_reset"})
+ else:
+ bridge.send({"type": "run_error", "error": f"Unknown websocket message type: {message_type}"})
+ except WebSocketDisconnect:
+ bridge.cancelled.set()
+ finally:
+ bridge.cancelled.set()
+ _release_managed_run(bridge)
+ sender_task.cancel()
diff --git a/frontend/static/app.css b/frontend/static/app.css
new file mode 100644
index 0000000000000000000000000000000000000000..2d81b1e450321c8e3d11a3bd960d7299486dcfbc
--- /dev/null
+++ b/frontend/static/app.css
@@ -0,0 +1,955 @@
+:root {
+ --bg: #ffffff;
+ --bar: #f5f5f5;
+ --border: #e8e8e8;
+ --panel: rgba(255, 255, 255, 0.82);
+ --panel-strong: rgba(255, 255, 255, 0.96);
+ --hover: #f7f7f7;
+ --text: #171717;
+ --muted: #747474;
+ --accent-start: #1a1a1a;
+ --accent-end: #333333;
+ --accent-text: #ffffff;
+ --glow-rgb: 0, 0, 0;
+ --danger: #b42318;
+ --ok: #1f7a42;
+ --warn: #9a6700;
+ --shadow: 0 18px 70px rgba(0, 0, 0, 0.08);
+}
+
+[data-theme="yellow"] {
+ --bg: #faf8f4;
+ --bar: #f0ebe1;
+ --border: #e5ddd0;
+ --panel: rgba(255, 252, 246, 0.84);
+ --panel-strong: rgba(255, 252, 246, 0.96);
+ --hover: #f0ece4;
+ --text: #2f2113;
+ --muted: #8a7055;
+ --accent-start: #1c1208;
+ --accent-end: #3a2410;
+ --accent-text: #ffffff;
+ --glow-rgb: 180, 128, 40;
+}
+
+[data-theme="blue"] {
+ --bg: #f3f5f8;
+ --bar: #e3e8ef;
+ --border: #d3dae3;
+ --panel: rgba(248, 251, 255, 0.84);
+ --panel-strong: rgba(248, 251, 255, 0.96);
+ --hover: #e8eef5;
+ --text: #172f4a;
+ --muted: #6a8aaa;
+ --accent-start: #1a3654;
+ --accent-end: #1e4a7a;
+ --accent-text: #ffffff;
+ --glow-rgb: 38, 88, 155;
+}
+
+[data-theme="dark"] {
+ --bg: #111110;
+ --bar: #1c1c1a;
+ --border: #2e2e2b;
+ --panel: rgba(28, 28, 26, 0.86);
+ --panel-strong: rgba(28, 28, 26, 0.98);
+ --hover: #242420;
+ --text: #e8e6df;
+ --muted: #8a8a80;
+ --accent-start: #e8e6df;
+ --accent-end: #d0cec7;
+ --accent-text: #111110;
+ --glow-rgb: 220, 210, 180;
+ --danger: #ffb4a9;
+ --ok: #9de8b5;
+ --warn: #f7d36f;
+ --shadow: 0 18px 70px rgba(0, 0, 0, 0.34);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--text);
+ font-family: "IBM Plex Sans", "Aptos", "Segoe UI Variable", "Noto Sans CJK SC", "Microsoft YaHei", "PingFang SC", sans-serif;
+ transition: background 0.3s ease, color 0.3s ease;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+.chat-shell {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ width: min(980px, 100%);
+ height: 100vh;
+ height: 100dvh;
+ min-height: 0;
+ overflow: hidden;
+ margin: 0 auto;
+ padding: 14px 16px 18px;
+}
+
+.chat-shell > * {
+ min-height: 0;
+}
+
+.topbar,
+.workspace-strip,
+.composer {
+ border: 1px solid var(--border);
+ background: var(--panel);
+ backdrop-filter: blur(18px);
+ box-shadow: var(--shadow);
+}
+
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 4;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ border-radius: 22px;
+ padding: 10px 12px;
+ background: var(--panel-strong);
+ box-shadow: 0 14px 38px rgba(var(--glow-rgb), 0.15), 0 3px 10px rgba(0, 0, 0, 0.08);
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.brand strong {
+ display: block;
+ letter-spacing: -0.02em;
+}
+
+.logo {
+ display: grid;
+ place-items: center;
+ width: 38px;
+ height: 38px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, var(--accent-start), var(--accent-end));
+ color: var(--accent-text);
+ font-size: 0.82rem;
+ font-weight: 900;
+}
+
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.status.running::before {
+ content: "";
+ width: 10px;
+ height: 10px;
+ border: 2px solid currentColor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: spin 0.82s linear infinite;
+}
+
+.status.running {
+ color: var(--warn);
+}
+
+.status.done {
+ color: var(--ok);
+}
+
+.status.error {
+ color: var(--danger);
+}
+
+.top-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.plain,
+.send-button,
+.icon-button {
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--panel-strong);
+ color: var(--text);
+ font-weight: 850;
+ transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
+}
+
+.plain {
+ padding: 8px 12px;
+}
+
+.plain:hover,
+.icon-button:hover {
+ border-color: rgba(var(--glow-rgb), 0.38);
+ transform: translateY(-1px);
+}
+
+.workspace-strip {
+ position: sticky;
+ top: 66px;
+ z-index: 4;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-items: center;
+ margin-top: 10px;
+ border-radius: 18px;
+ padding: 9px 12px;
+ background: var(--panel-strong);
+ box-shadow: 0 14px 38px rgba(var(--glow-rgb), 0.15), 0 3px 10px rgba(0, 0, 0, 0.08);
+}
+
+.workspace-strip input {
+ display: none;
+}
+
+.workspace-strip span {
+ display: block;
+ min-width: 0;
+ max-width: 100%;
+ color: var(--muted);
+ font-size: 0.82rem;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ white-space: normal;
+}
+
+.messages {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ gap: 14px;
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ max-height: 100%;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ overscroll-behavior: contain;
+ padding: 24px 4px 18px;
+ scrollbar-gutter: stable;
+ -webkit-overflow-scrolling: touch;
+}
+
+.messages::-webkit-scrollbar,
+.workspace-list::-webkit-scrollbar {
+ width: 10px;
+}
+
+.messages::-webkit-scrollbar-thumb,
+.workspace-list::-webkit-scrollbar-thumb {
+ border: 3px solid transparent;
+ border-radius: 999px;
+ background: rgba(var(--glow-rgb), 0.24);
+ background-clip: padding-box;
+}
+
+.welcome {
+ margin: auto;
+ max-width: 650px;
+ text-align: center;
+}
+
+.welcome h1 {
+ margin: 0;
+ font-size: clamp(2.2rem, 6vw, 4.7rem);
+ line-height: 0.94;
+ letter-spacing: -0.055em;
+}
+
+.welcome p {
+ margin: 18px auto 0;
+ max-width: 520px;
+ color: var(--muted);
+ line-height: 1.6;
+}
+
+.message,
+.event {
+ flex: 0 0 auto;
+ border: 1px solid var(--border);
+ border-radius: 22px;
+ background: var(--panel);
+ backdrop-filter: blur(18px);
+ box-shadow: 0 10px 34px rgba(0, 0, 0, 0.05);
+ overflow: hidden;
+}
+
+.message {
+ max-width: min(760px, 92%);
+}
+
+.event {
+ width: min(760px, 92%);
+}
+
+.message.user {
+ align-self: flex-end;
+ background: linear-gradient(135deg, var(--accent-start), var(--accent-end));
+ color: var(--accent-text);
+}
+
+.message.assistant,
+.event {
+ align-self: flex-start;
+}
+
+.message-body {
+ padding: 14px 16px;
+}
+
+.event-body {
+ max-height: none;
+ overflow: hidden;
+ transition: max-height 0.24s ease;
+}
+
+.event-body-inner {
+ padding: 14px 16px;
+}
+
+.event.collapsed .event-body {
+ max-height: 220px;
+}
+
+.event.collapsed .event-body-inner {
+ position: relative;
+ overflow: hidden;
+}
+
+.event.collapsed .event-body-inner::after {
+ content: "";
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 60px;
+ background: linear-gradient(to bottom, transparent, var(--panel-strong));
+ pointer-events: none;
+}
+
+.event.can-collapse {
+ cursor: pointer;
+}
+
+.event.latest {
+ cursor: default;
+}
+
+.event:not(.can-collapse) .event-toggle {
+ display: none;
+}
+
+.message-body pre,
+.event-body pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.86rem;
+ line-height: 1.5;
+}
+
+.markdown-body {
+ line-height: 1.6;
+ word-break: break-word;
+}
+
+.markdown-body > *:first-child {
+ margin-top: 0;
+}
+
+.markdown-body > *:last-child {
+ margin-bottom: 0;
+}
+
+.markdown-body p,
+.markdown-body ul,
+.markdown-body ol,
+.markdown-body blockquote,
+.markdown-body table,
+.markdown-body pre {
+ margin: 0 0 0.8rem;
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4,
+.markdown-body h5,
+.markdown-body h6 {
+ margin: 0 0 0.65rem;
+ line-height: 1.2;
+}
+
+.markdown-body ul,
+.markdown-body ol {
+ padding-left: 1.35rem;
+}
+
+.markdown-body code {
+ padding: 0.1rem 0.28rem;
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.08);
+ font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.88em;
+}
+
+.markdown-body pre {
+ padding: 12px;
+ border-radius: 14px;
+ background: rgba(0, 0, 0, 0.08);
+ overflow-x: auto;
+}
+
+.markdown-body pre code {
+ padding: 0;
+ background: transparent;
+}
+
+.markdown-body blockquote {
+ padding-left: 0.85rem;
+ border-left: 3px solid rgba(var(--glow-rgb), 0.35);
+ color: var(--muted);
+}
+
+.markdown-body a {
+ color: inherit;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+.markdown-body table {
+ width: 100%;
+ border-collapse: collapse;
+ overflow: hidden;
+ border-radius: 14px;
+}
+
+.markdown-body th,
+.markdown-body td {
+ padding: 8px 10px;
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ text-align: left;
+ vertical-align: top;
+}
+
+.message-images {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.message-image {
+ max-width: 180px;
+ max-height: 180px;
+ border-radius: 16px;
+ object-fit: cover;
+ border: 1px solid rgba(255, 255, 255, 0.24);
+}
+
+.event-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.event-title {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ font-weight: 900;
+}
+
+.event-toggle {
+ flex: 0 0 auto;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--panel-strong);
+ color: var(--muted);
+ font-size: 0.76rem;
+ font-weight: 850;
+ padding: 5px 9px;
+}
+
+.event.latest .event-toggle {
+ display: none;
+}
+
+.event:not(.collapsed) .event-toggle::after {
+ content: "collapse";
+}
+
+.event.collapsed .event-toggle::after {
+ content: "expand";
+}
+
+.badge {
+ border-radius: 999px;
+ background: rgba(var(--glow-rgb), 0.11);
+ color: var(--text);
+ font-size: 0.72rem;
+ font-weight: 850;
+ padding: 4px 8px;
+}
+
+.tool-grid {
+ display: grid;
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.tool-call {
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 11px;
+ background: color-mix(in srgb, var(--hover), transparent 28%);
+}
+
+.tool-name {
+ margin-bottom: 8px;
+ font-weight: 900;
+}
+
+.error-text {
+ color: var(--danger);
+}
+
+.muted-text {
+ color: var(--muted);
+}
+
+.composer textarea {
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text);
+}
+
+.composer-wrap {
+ position: sticky;
+ bottom: 0;
+ z-index: 4;
+ display: grid;
+ gap: 8px;
+}
+
+.composer {
+ display: flex;
+ align-items: flex-end;
+ gap: 10px;
+ border-radius: 26px;
+ padding: 11px;
+ background: var(--panel-strong);
+ box-shadow: 0 14px 38px rgba(var(--glow-rgb), 0.15), 0 3px 10px rgba(0, 0, 0, 0.08);
+}
+
+.composer.dragover {
+ border-color: rgba(var(--glow-rgb), 0.44);
+ box-shadow: 0 0 0 5px rgba(var(--glow-rgb), 0.09), var(--shadow);
+}
+
+.composer textarea {
+ flex: 1;
+ max-height: 180px;
+ min-height: 30px;
+ resize: none;
+ line-height: 1.5;
+ padding: 7px 0;
+}
+
+.icon-button,
+.send-button {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ height: 38px;
+ min-width: 38px;
+}
+
+.icon-button {
+ font-size: 1.35rem;
+ line-height: 1;
+}
+
+.send-button {
+ padding: 0 16px;
+ background: linear-gradient(135deg, var(--accent-start), var(--accent-end));
+ color: var(--accent-text);
+}
+
+.send-button.is-running {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.send-button.is-running::before {
+ content: "";
+ width: 12px;
+ height: 12px;
+ margin-right: 8px;
+ border: 2px solid currentColor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: spin 0.82s linear infinite;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.58;
+ transform: none;
+}
+
+#imageInput {
+ display: none;
+}
+
+.image-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 0 8px;
+}
+
+.image-chip {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ max-width: 240px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 5px 10px 5px 5px;
+ background: var(--panel);
+ color: var(--text);
+}
+
+.image-chip img {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.image-chip span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.82rem;
+}
+
+.composer-hint {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.78rem;
+ text-align: center;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: grid;
+ place-items: center;
+ padding: 18px;
+ background: rgba(0, 0, 0, 0.24);
+ backdrop-filter: blur(14px);
+}
+
+.modal.hidden {
+ display: none;
+}
+
+.modal-card {
+ display: grid;
+ grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+ gap: 12px;
+ width: min(780px, 100%);
+ max-height: min(760px, 82vh);
+ border: 1px solid var(--border);
+ border-radius: 28px;
+ background: var(--panel-strong);
+ box-shadow: 0 24px 88px rgba(0, 0, 0, 0.22);
+ padding: 18px;
+}
+
+.modal-head,
+.modal-path-row,
+.modal-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.modal-head {
+ justify-content: space-between;
+}
+
+.modal-head h2,
+.modal-head p {
+ margin: 0;
+}
+
+.modal-head h2 {
+ font-size: 1.18rem;
+ letter-spacing: -0.025em;
+}
+
+.modal-head p,
+.modal-actions span {
+ color: var(--muted);
+ font-size: 0.86rem;
+}
+
+.modal-path-row {
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--hover);
+ padding: 8px;
+}
+
+.modal-path-row input {
+ min-width: 0;
+ flex: 1;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text);
+}
+
+.workspace-roots {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.root-chip {
+ max-width: 190px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--panel);
+ color: var(--text);
+ font-weight: 800;
+ padding: 7px 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workspace-list {
+ display: grid;
+ align-content: start;
+ gap: 7px;
+ min-height: 0;
+ overflow: auto;
+ padding-right: 4px;
+}
+
+.dir-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--panel);
+ color: var(--text);
+ padding: 10px 12px;
+ text-align: left;
+}
+
+.dir-row:hover,
+.root-chip:hover {
+ border-color: rgba(var(--glow-rgb), 0.38);
+ background: var(--hover);
+}
+
+.dir-icon {
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: rgba(var(--glow-rgb), 0.1);
+ font-weight: 900;
+}
+
+.dir-main {
+ min-width: 0;
+}
+
+.dir-main strong,
+.dir-main small {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dir-main small {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.dir-action {
+ color: var(--muted);
+ font-size: 0.76rem;
+ font-weight: 850;
+}
+
+.dir-empty {
+ border: 1px dashed var(--border);
+ border-radius: 18px;
+ padding: 18px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.modal-actions {
+ justify-content: space-between;
+}
+
+#theme-switcher {
+ position: fixed;
+ right: 22px;
+ bottom: 22px;
+ z-index: 20;
+ display: flex;
+ gap: 9px;
+ padding: 9px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--bar);
+ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.12);
+}
+
+.theme-dot {
+ width: 21px;
+ height: 21px;
+ border: 1.5px solid transparent;
+ border-radius: 50%;
+ padding: 0;
+ transition: transform 0.18s ease, box-shadow 0.18s ease;
+}
+
+.theme-dot[data-theme="white"] {
+ background: #ffffff;
+ border-color: #c8c8c8;
+}
+
+.theme-dot[data-theme="yellow"] {
+ background: #e8d5a0;
+ border-color: #c4a060;
+}
+
+.theme-dot[data-theme="blue"] {
+ background: #a8c4e8;
+ border-color: #5e8ec8;
+}
+
+.theme-dot[data-theme="dark"] {
+ background: #2a2a26;
+ border-color: #585852;
+}
+
+.theme-dot.active {
+ box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--border);
+ transform: scale(1.08);
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (max-width: 720px) {
+ .chat-shell {
+ padding: 10px;
+ }
+
+ .topbar,
+ .workspace-strip {
+ grid-template-columns: 1fr;
+ }
+
+ .topbar {
+ align-items: flex-start;
+ }
+
+ .workspace-strip {
+ display: grid;
+ }
+
+ .workspace-strip span {
+ max-width: none;
+ }
+
+ .modal-card {
+ max-height: 88vh;
+ padding: 14px;
+ }
+
+ .modal-head,
+ .modal-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .modal-path-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .message,
+ .event {
+ max-width: 96%;
+ }
+
+ #theme-switcher {
+ right: 12px;
+ bottom: 12px;
+ }
+}
diff --git a/frontend/static/app.js b/frontend/static/app.js
new file mode 100644
index 0000000000000000000000000000000000000000..540038184267a2f3611cbc1817a4f2af57849098
--- /dev/null
+++ b/frontend/static/app.js
@@ -0,0 +1,743 @@
+(function () {
+ var canvas, ctx, t = 0;
+ var TILE = 26, GAP = 1;
+ var rafId = null;
+ var cachedRgb = "10,10,10";
+ var lastDraw = 0;
+ var FRAME_MS = 1000 / 24;
+
+ function rgb() {
+ var th = document.documentElement.getAttribute("data-theme") || "white";
+ if (th === "dark") return "220,210,175";
+ if (th === "yellow") return "120,85,20";
+ if (th === "blue") return "38,88,155";
+ return "10,10,10";
+ }
+
+ function frame(ts) {
+ rafId = requestAnimationFrame(frame);
+ if (ts - lastDraw < FRAME_MS) return;
+ lastDraw = ts;
+
+ var w = canvas.width, h = canvas.height;
+ var cols = Math.ceil(w / TILE) + 1;
+ var rows = Math.ceil(h / TILE) + 1;
+ var pre = "rgba(" + cachedRgb + ",";
+
+ ctx.clearRect(0, 0, w, h);
+ for (var r = 0; r < rows; r++) {
+ for (var c = 0; c < cols; c++) {
+ var wave = 0.6 * Math.sin(c * 0.21 + t * 0.36) * Math.sin(r * 0.17 + t * 0.28)
+ + 0.4 * Math.sin(c * 0.11 - r * 0.13 + t * 0.19);
+ var norm = (wave + 1) * 0.5;
+ var v = norm * norm * norm;
+ var a = Math.round((0.004 + v * 0.186) * 100) / 100;
+ if (a < 0.02) continue;
+ ctx.fillStyle = pre + a + ")";
+ ctx.fillRect(c * TILE + GAP, r * TILE + GAP, TILE - GAP, TILE - GAP);
+ }
+ }
+ t += 0.007;
+ }
+
+ var resizeTimer;
+ function resize() {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(function () {
+ var newW = window.innerWidth;
+ var newH = window.innerHeight;
+ if (newW === canvas.width && Math.abs(newH - canvas.height) <= 90) return;
+ canvas.width = newW;
+ canvas.height = newH;
+ }, 120);
+ }
+
+ function onVisibilityChange() {
+ if (document.hidden) {
+ if (rafId) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ } else if (!rafId) {
+ rafId = requestAnimationFrame(frame);
+ }
+ }
+
+ document.addEventListener("DOMContentLoaded", function () {
+ canvas = document.createElement("canvas");
+ canvas.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;"
+ + "z-index:0;pointer-events:none;will-change:transform;"
+ + "-webkit-backface-visibility:hidden;backface-visibility:hidden;";
+ document.body.insertBefore(canvas, document.body.firstChild);
+ ctx = canvas.getContext("2d");
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+ cachedRgb = rgb();
+ window.addEventListener("resize", resize);
+ document.addEventListener("visibilitychange", onVisibilityChange);
+ rafId = requestAnimationFrame(frame);
+ });
+
+ new MutationObserver(function () { cachedRgb = rgb(); })
+ .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
+})();
+
+(function () {
+ var THEMES = ["white", "yellow", "blue", "dark"];
+ var LABELS = { white: "Pure White", yellow: "Warm Yellow", blue: "Cool Blue", dark: "Dark" };
+
+ function applyTheme(theme) {
+ if (theme === "white") {
+ document.documentElement.removeAttribute("data-theme");
+ } else {
+ document.documentElement.setAttribute("data-theme", theme);
+ }
+ try { localStorage.setItem("rh-ui-theme", theme); } catch (e) {}
+ document.querySelectorAll(".theme-dot").forEach(function (dot) {
+ dot.classList.toggle("active", dot.dataset.theme === theme);
+ });
+ }
+
+ var saved = "white";
+ try { saved = localStorage.getItem("rh-ui-theme") || "white"; } catch (e) {}
+ applyTheme(saved);
+
+ document.addEventListener("DOMContentLoaded", function () {
+ var switcher = document.createElement("div");
+ switcher.id = "theme-switcher";
+ switcher.setAttribute("aria-label", "Choose colour theme");
+ THEMES.forEach(function (theme) {
+ var btn = document.createElement("button");
+ btn.className = "theme-dot";
+ btn.dataset.theme = theme;
+ btn.title = LABELS[theme];
+ btn.setAttribute("aria-label", LABELS[theme]);
+ btn.addEventListener("click", function () { applyTheme(theme); });
+ switcher.appendChild(btn);
+ });
+ document.body.appendChild(switcher);
+ applyTheme(saved);
+ });
+})();
+
+(function () {
+ var ws;
+ var running = false;
+ var interrupting = false;
+ var pendingAskId = "";
+ var keepSubmittedMessageOnReset = false;
+ var autoFollowTimeline = true;
+ var conversationStarted = false;
+ var images = [];
+ var COLLAPSED_STEP_HEIGHT = 220;
+
+ var workspaceInput = document.getElementById("workspaceInput");
+ var workspaceStrip = document.getElementById("workspaceStrip");
+ var promptInput = document.getElementById("promptInput");
+ var runBtn = document.getElementById("runBtn");
+ var newBtn = document.getElementById("newBtn");
+ var pickWorkspaceBtn = document.getElementById("pickWorkspaceBtn");
+ var attachBtn = document.getElementById("attachBtn");
+ var imageInput = document.getElementById("imageInput");
+ var imagePreview = document.getElementById("imagePreview");
+ var dropZone = document.getElementById("dropZone");
+ var timeline = document.getElementById("timeline");
+ var statusPill = document.getElementById("statusPill");
+ var workspaceMeta = document.getElementById("workspaceMeta");
+ var workspaceModal = document.getElementById("workspaceModal");
+ var workspaceCloseBtn = document.getElementById("workspaceCloseBtn");
+ var workspacePathInput = document.getElementById("workspacePathInput");
+ var workspaceGoBtn = document.getElementById("workspaceGoBtn");
+ var workspaceRoots = document.getElementById("workspaceRoots");
+ var workspaceList = document.getElementById("workspaceList");
+ var workspaceUseBtn = document.getElementById("workspaceUseBtn");
+ var workspacePickerHint = document.getElementById("workspacePickerHint");
+ var currentWorkspacePath = "";
+ var defaultPromptPlaceholder = promptInput.getAttribute("placeholder") || "Message ResearchHarness";
+
+ function escapeHtml(value) {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+ }
+
+ function renderMarkdown(text) {
+ if (!window.marked || !window.DOMPurify) {
+ console.warn("Markdown renderer unavailable; falling back to plain text.");
+ return "" + escapeHtml(text) + "
";
+ }
+ try {
+ var rawHtml = window.marked.parse(String(text || ""), { gfm: true, breaks: false, async: false });
+ var safeHtml = window.DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } });
+ return '' + safeHtml + "
";
+ } catch (e) {
+ console.warn("Markdown rendering failed; falling back to plain text.", e);
+ return "" + escapeHtml(text) + "
";
+ }
+ }
+
+ function setStatus(text, kind) {
+ statusPill.textContent = text;
+ statusPill.className = "status " + (kind || "idle");
+ }
+
+ function setWorkspaceSelected(path) {
+ workspaceInput.value = path;
+ workspaceMeta.textContent = "Workspace selected: " + path;
+ }
+
+ function updateComposerMode() {
+ if (pendingAskId) {
+ runBtn.disabled = false;
+ runBtn.classList.remove("is-running");
+ runBtn.textContent = "Reply";
+ promptInput.placeholder = defaultPromptPlaceholder;
+ return;
+ }
+ runBtn.disabled = running && interrupting;
+ runBtn.classList.toggle("is-running", running);
+ runBtn.textContent = running ? (interrupting ? "Stopping" : "Stop") : "Run";
+ promptInput.placeholder = defaultPromptPlaceholder;
+ }
+
+ function setRunning(active, statusText) {
+ running = active;
+ if (!active) interrupting = false;
+ updateComposerMode();
+ setStatus(statusText || (active ? "Running" : "Idle"), active ? "running" : "idle");
+ }
+
+ function clearTimeline() {
+ autoFollowTimeline = true;
+ timeline.innerHTML = ''
+ + ''
+ + '
What should the agent do?
'
+ + '
Ask a question, attach images, choose a local workspace, and watch tool calls stream here.
'
+ + '
';
+ }
+
+ function ensureTimelineReady() {
+ var welcome = timeline.querySelector(".welcome");
+ if (welcome) welcome.remove();
+ }
+
+ function isNearBottom() {
+ return timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 80;
+ }
+
+ function scrollTimeline(force) {
+ if (!force && !autoFollowTimeline) return;
+ requestAnimationFrame(function () {
+ timeline.scrollTop = timeline.scrollHeight;
+ requestAnimationFrame(function () {
+ timeline.scrollTop = timeline.scrollHeight;
+ autoFollowTimeline = isNearBottom();
+ });
+ });
+ }
+
+ function syncTimelineFollowMode() {
+ autoFollowTimeline = isNearBottom();
+ }
+
+ function updateEventToggle(node) {
+ var toggle = node.querySelector(".event-toggle");
+ if (!toggle) return;
+ toggle.setAttribute("aria-expanded", node.classList.contains("collapsed") ? "false" : "true");
+ }
+
+ function eventBody(node) {
+ return node.querySelector(".event-body");
+ }
+
+ function eventCanCollapse(node) {
+ return node.classList.contains("can-collapse");
+ }
+
+ function refreshEventCollapseCapability(node) {
+ var body = eventBody(node);
+ var toggle = node.querySelector(".event-toggle");
+ if (!body) return;
+ var shouldCollapse = body.scrollHeight > COLLAPSED_STEP_HEIGHT + 8;
+ node.classList.toggle("can-collapse", shouldCollapse);
+ if (toggle) toggle.hidden = !shouldCollapse;
+ if (!shouldCollapse) {
+ node.classList.remove("collapsed");
+ body.style.maxHeight = "none";
+ }
+ updateEventToggle(node);
+ }
+
+ function setEventExpanded(node, expanded, animate) {
+ var body = eventBody(node);
+ if (!body) {
+ node.classList.toggle("collapsed", !expanded);
+ updateEventToggle(node);
+ return;
+ }
+ refreshEventCollapseCapability(node);
+ if (!eventCanCollapse(node)) return;
+
+ if (expanded) {
+ node.classList.remove("collapsed");
+ body.style.maxHeight = body.scrollHeight + "px";
+ if (!animate) {
+ body.style.maxHeight = "none";
+ } else {
+ body.addEventListener("transitionend", function onEnd(event) {
+ if (event.propertyName !== "max-height") return;
+ body.removeEventListener("transitionend", onEnd);
+ if (!node.classList.contains("collapsed")) {
+ body.style.maxHeight = "none";
+ }
+ });
+ }
+ } else {
+ if (body.style.maxHeight === "none" || !body.style.maxHeight) {
+ body.style.maxHeight = body.scrollHeight + "px";
+ }
+ body.offsetHeight;
+ node.classList.add("collapsed");
+ body.style.maxHeight = COLLAPSED_STEP_HEIGHT + "px";
+ }
+ updateEventToggle(node);
+ }
+
+ function toggleEvent(node) {
+ if (node.classList.contains("latest") || !eventCanCollapse(node)) return;
+ setEventExpanded(node, node.classList.contains("collapsed"), true);
+ }
+
+ function addEvent(kind, title, bodyHtml, badges) {
+ var shouldFollow = autoFollowTimeline || isNearBottom();
+ ensureTimelineReady();
+ timeline.querySelectorAll(".event.latest").forEach(function (eventNode) {
+ eventNode.classList.remove("latest");
+ setEventExpanded(eventNode, false, true);
+ updateEventToggle(eventNode);
+ });
+ var badgeHtml = (badges || []).map(function (badge) {
+ return '' + escapeHtml(badge) + "";
+ }).join("");
+ var node = document.createElement("article");
+ node.className = "event event-" + kind + " latest";
+ node.innerHTML = ''
+ + ''
+ + '
' + escapeHtml(title) + badgeHtml + '
'
+ + '
'
+ + '
'
+ + '';
+ node.querySelector(".event-toggle").addEventListener("click", function (event) {
+ event.stopPropagation();
+ toggleEvent(node);
+ });
+ node.addEventListener("click", function () {
+ toggleEvent(node);
+ });
+ timeline.appendChild(node);
+ setEventExpanded(node, true, false);
+ scrollTimeline(shouldFollow);
+ }
+
+ function addMessage(kind, text, attachedImages) {
+ autoFollowTimeline = true;
+ ensureTimelineReady();
+ var node = document.createElement("article");
+ node.className = "message " + kind;
+ var imageHtml = "";
+ (attachedImages || []).forEach(function (image) {
+ imageHtml += '
';
+ });
+ node.innerHTML = ''
+ + (imageHtml ? '
' + imageHtml + '
' : '')
+ + '
' + escapeHtml(text) + '
'
+ + '
';
+ timeline.appendChild(node);
+ scrollTimeline(true);
+ }
+
+ function formatJson(value) {
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch (e) {
+ return String(value);
+ }
+ }
+
+ function renderTrace(row) {
+ if (!row || row.capture_type === "llm_call" || row.capture_type === "compaction") return;
+ var role = row.role || "";
+ var turn = row.turn_index || 0;
+ var text = row.text || "";
+ if (role === "system") return;
+ if (role === "user" && turn === 0) return;
+
+ if (role === "assistant") {
+ var tools = Array.isArray(row.tool_names) ? row.tool_names : [];
+ var args = Array.isArray(row.tool_arguments) ? row.tool_arguments : [];
+ var body = "";
+ if (text.trim()) {
+ body += (!tools.length && row.termination === "result")
+ ? renderMarkdown(text)
+ : "" + escapeHtml(text) + "
";
+ }
+ if (tools.length) {
+ body += '";
+ }
+ if (!body) body = '(empty assistant output)
';
+ if (row.error) body += '' + escapeHtml(row.error) + "
";
+ addEvent("assistant", "Assistant", body, ["round " + turn]);
+ return;
+ }
+
+ if (role === "tool") {
+ var toolName = Array.isArray(row.tool_names) && row.tool_names.length ? row.tool_names[0] : "Tool";
+ var toolBody = "" + escapeHtml(text) + "
";
+ if (row.error) toolBody += '' + escapeHtml(row.error) + "
";
+ addEvent("tool", toolName + " result", toolBody, ["round " + turn]);
+ return;
+ }
+
+ if (role === "runtime") {
+ if (!text.trim() && !row.error && !row.termination) return;
+ var runtimeBody = "" + escapeHtml(text || row.termination || "") + "
";
+ if (row.error) runtimeBody += '' + escapeHtml(row.error) + "
";
+ addEvent("runtime", "Runtime", runtimeBody, turn ? ["round " + turn] : []);
+ return;
+ }
+
+ if (role === "user") {
+ addEvent("runtime", "Runtime message", "" + escapeHtml(text) + "
", ["round " + turn]);
+ }
+ }
+
+ function connect() {
+ var protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ ws = new WebSocket(protocol + "//" + window.location.host + "/ws");
+ ws.onopen = function () {
+ setStatus("Connected", "idle");
+ };
+ ws.onclose = function () {
+ clearAskRequest();
+ setRunning(false, "Disconnected");
+ setStatus("Disconnected", "error");
+ };
+ ws.onmessage = function (event) {
+ var message = JSON.parse(event.data);
+ if (message.type === "ready") {
+ setStatus("Connected", "idle");
+ } else if (message.type === "conversation_reset") {
+ if (keepSubmittedMessageOnReset) {
+ keepSubmittedMessageOnReset = false;
+ ensureTimelineReady();
+ } else {
+ clearTimeline();
+ }
+ conversationStarted = false;
+ clearAskRequest();
+ } else if (message.type === "uploaded_images") {
+ addEvent("runtime", "Uploaded images saved", "" + escapeHtml((message.paths || []).join("\n")) + "", []);
+ } else if (message.type === "run_started") {
+ setRunning(true, "Running");
+ } else if (message.type === "interrupt_requested") {
+ interrupting = true;
+ updateComposerMode();
+ setStatus("Interrupting", "running");
+ } else if (message.type === "trace") {
+ renderTrace(message.row);
+ } else if (message.type === "ask_user") {
+ showAskRequest(message);
+ } else if (message.type === "run_finished") {
+ conversationStarted = true;
+ setRunning(false, "Done");
+ clearAskRequest();
+ setStatus("Done", "done");
+ } else if (message.type === "run_error") {
+ keepSubmittedMessageOnReset = false;
+ clearAskRequest();
+ setRunning(false, "Error");
+ setStatus("Error", "error");
+ addEvent("runtime", "Error", '' + escapeHtml(message.error || "unknown error") + "
", []);
+ }
+ };
+ }
+
+ function showAskRequest(message) {
+ pendingAskId = message.request_id || "";
+ var question = message.question || "Question";
+ var context = message.context || "";
+ var body = "" + escapeHtml(question) + "
";
+ if (context) body += '' + escapeHtml(context) + "
";
+ addEvent("runtime", "Agent question", body, ["AskUser"]);
+ setStatus("Waiting for input", "running");
+ updateComposerMode();
+ promptInput.focus();
+ }
+
+ function clearAskRequest() {
+ pendingAskId = "";
+ updateComposerMode();
+ }
+
+ function sendStart() {
+ if (pendingAskId) {
+ sendAskUserAnswer();
+ return;
+ }
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ setStatus("Disconnected", "error");
+ return;
+ }
+ if (running) {
+ sendInterrupt();
+ return;
+ }
+ var prompt = promptInput.value.trim();
+ if (!prompt) return;
+ var sentImages = images.slice();
+ var continueConversation = conversationStarted;
+ if (!continueConversation) clearTimeline();
+ addMessage("user", prompt, sentImages);
+ keepSubmittedMessageOnReset = !continueConversation;
+ setRunning(true, "Starting");
+ ws.send(JSON.stringify({
+ type: "start",
+ prompt: prompt,
+ workspace_root: workspaceInput.value,
+ images: sentImages,
+ continue_conversation: continueConversation
+ }));
+ promptInput.value = "";
+ promptInput.style.height = "auto";
+ images = [];
+ renderImages();
+ }
+
+ function sendInterrupt() {
+ if (!running || interrupting || !ws || ws.readyState !== WebSocket.OPEN) return;
+ interrupting = true;
+ updateComposerMode();
+ setStatus("Interrupting", "running");
+ ws.send(JSON.stringify({ type: "interrupt" }));
+ }
+
+ function sendAskUserAnswer() {
+ if (!pendingAskId || !ws || ws.readyState !== WebSocket.OPEN) return;
+ var answer = promptInput.value.trim();
+ if (!answer) return;
+ var requestId = pendingAskId;
+ addMessage("user", answer, []);
+ ws.send(JSON.stringify({ type: "ask_user_answer", request_id: requestId, answer: answer }));
+ pendingAskId = "";
+ promptInput.value = "";
+ promptInput.style.height = "auto";
+ updateComposerMode();
+ setStatus("Running", "running");
+ }
+
+ function addImageFiles(fileList) {
+ Array.from(fileList || []).forEach(function (file) {
+ if (!file.type || !file.type.startsWith("image/")) return;
+ var reader = new FileReader();
+ reader.onload = function () {
+ images.push({ name: file.name, data_url: String(reader.result || "") });
+ renderImages();
+ };
+ reader.readAsDataURL(file);
+ });
+ }
+
+ function renderImages() {
+ imagePreview.innerHTML = "";
+ images.forEach(function (image, idx) {
+ var chip = document.createElement("button");
+ chip.type = "button";
+ chip.className = "image-chip";
+ chip.title = "Remove image";
+ chip.innerHTML = '
' + escapeHtml(image.name || "image") + "";
+ chip.addEventListener("click", function () {
+ images.splice(idx, 1);
+ renderImages();
+ });
+ imagePreview.appendChild(chip);
+ });
+ }
+
+ function openWorkspaceModal() {
+ workspaceModal.classList.remove("hidden");
+ loadWorkspaceDirectory(workspaceInput.value.trim());
+ }
+
+ function closeWorkspaceModal() {
+ workspaceModal.classList.add("hidden");
+ }
+
+ function setWorkspacePickerBusy(text) {
+ workspaceList.innerHTML = '' + escapeHtml(text || "Loading...") + "
";
+ workspacePickerHint.textContent = text || "Loading...";
+ }
+
+ function renderWorkspaceError(message) {
+ workspaceList.innerHTML = '' + escapeHtml(message) + "
";
+ workspacePickerHint.textContent = "Paste a valid existing folder path, then press Go.";
+ }
+
+ function directoryRow(label, path, actionLabel, onClick) {
+ var row = document.createElement("button");
+ row.type = "button";
+ row.className = "dir-row";
+ row.innerHTML = ''
+ + '›'
+ + '' + escapeHtml(label) + '' + escapeHtml(path) + ''
+ + '' + escapeHtml(actionLabel || "Open") + '';
+ row.addEventListener("click", onClick);
+ return row;
+ }
+
+ function renderWorkspacePicker(payload) {
+ currentWorkspacePath = payload.path || "";
+ workspacePathInput.value = currentWorkspacePath;
+ workspaceRoots.innerHTML = "";
+ (payload.roots || []).forEach(function (root) {
+ var chip = document.createElement("button");
+ chip.type = "button";
+ chip.className = "root-chip";
+ chip.textContent = root.label || root.path;
+ chip.title = root.path || "";
+ chip.addEventListener("click", function () {
+ loadWorkspaceDirectory(root.path || "");
+ });
+ workspaceRoots.appendChild(chip);
+ });
+
+ workspaceList.innerHTML = "";
+ if (payload.parent) {
+ workspaceList.appendChild(directoryRow("..", payload.parent, "Parent", function () {
+ loadWorkspaceDirectory(payload.parent);
+ }));
+ }
+ (payload.entries || []).forEach(function (entry) {
+ workspaceList.appendChild(directoryRow(entry.name, entry.path, "Open", function () {
+ loadWorkspaceDirectory(entry.path);
+ }));
+ });
+ if (!payload.parent && !(payload.entries || []).length) {
+ workspaceList.innerHTML = 'No readable child folders.
';
+ }
+ workspacePickerHint.textContent = payload.truncated
+ ? "Directory list was truncated. Paste a deeper path if needed."
+ : "Current folder will be used when you click Use this folder.";
+ }
+
+ async function loadWorkspaceDirectory(path) {
+ setWorkspacePickerBusy("Loading folders...");
+ try {
+ var url = "/api/workspace-directories";
+ if (path) url += "?path=" + encodeURIComponent(path);
+ var response = await fetch(url);
+ var payload = await response.json();
+ if (!response.ok || payload.error) {
+ renderWorkspaceError(payload.error || "Cannot open this folder.");
+ return;
+ }
+ renderWorkspacePicker(payload);
+ } catch (error) {
+ renderWorkspaceError(String(error));
+ }
+ }
+
+ runBtn.addEventListener("click", sendStart);
+ timeline.addEventListener("scroll", syncTimelineFollowMode);
+ timeline.addEventListener("wheel", function (event) {
+ if (event.deltaY < 0) autoFollowTimeline = false;
+ }, { passive: true });
+ timeline.addEventListener("touchmove", function () {
+ autoFollowTimeline = false;
+ }, { passive: true });
+ promptInput.addEventListener("keydown", function (event) {
+ if (event.isComposing) return;
+ if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
+ event.preventDefault();
+ sendStart();
+ }
+ });
+ promptInput.addEventListener("input", function () {
+ promptInput.style.height = "auto";
+ promptInput.style.height = Math.min(promptInput.scrollHeight, 180) + "px";
+ });
+ newBtn.addEventListener("click", function () {
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "new" }));
+ if (!running) {
+ promptInput.value = "";
+ images = [];
+ renderImages();
+ clearTimeline();
+ clearAskRequest();
+ conversationStarted = false;
+ setRunning(false, "Idle");
+ }
+ });
+ attachBtn.addEventListener("click", function () {
+ imageInput.click();
+ });
+ imageInput.addEventListener("change", function (event) { addImageFiles(event.target.files); });
+
+ pickWorkspaceBtn.addEventListener("click", function () {
+ openWorkspaceModal();
+ });
+
+ workspaceCloseBtn.addEventListener("click", closeWorkspaceModal);
+ workspaceModal.addEventListener("click", function (event) {
+ if (event.target === workspaceModal) closeWorkspaceModal();
+ });
+ workspaceGoBtn.addEventListener("click", function () {
+ loadWorkspaceDirectory(workspacePathInput.value.trim());
+ });
+ workspacePathInput.addEventListener("keydown", function (event) {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ loadWorkspaceDirectory(workspacePathInput.value.trim());
+ }
+ });
+ workspaceUseBtn.addEventListener("click", function () {
+ if (!currentWorkspacePath) return;
+ setWorkspaceSelected(currentWorkspacePath);
+ closeWorkspaceModal();
+ });
+
+ ["dragenter", "dragover"].forEach(function (name) {
+ dropZone.addEventListener(name, function (event) {
+ event.preventDefault();
+ dropZone.classList.add("dragover");
+ });
+ });
+ ["dragleave", "drop"].forEach(function (name) {
+ dropZone.addEventListener(name, function (event) {
+ event.preventDefault();
+ dropZone.classList.remove("dragover");
+ });
+ });
+ dropZone.addEventListener("drop", function (event) {
+ addImageFiles(event.dataTransfer.files);
+ });
+ document.addEventListener("paste", function (event) {
+ var files = [];
+ Array.from(event.clipboardData ? event.clipboardData.items : []).forEach(function (item) {
+ if (item.kind === "file") {
+ var file = item.getAsFile();
+ if (file) files.push(file);
+ }
+ });
+ if (files.length) addImageFiles(files);
+ });
+
+ connect();
+})();
diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2bccaa0217f1e098ecbff18b123fe7c88f8be1b8
--- /dev/null
+++ b/frontend/static/favicon.svg
@@ -0,0 +1,10 @@
+
diff --git a/frontend/static/index.html b/frontend/static/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..19844a718e225b465c2fceed974627e9c20c4731
--- /dev/null
+++ b/frontend/static/index.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+ ResearchHarness Chat
+
+
+
+
+
+
+
+
+
+ Managed temporary workspace. Each chat uses an isolated runtime directory.
+
+
+
+
+
What should the agent do?
+
Ask a question, attach images, and watch tool calls stream from an isolated temporary workspace.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..35bb0171e234380ac41f180fee018d09c967c78d
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,8 @@
+fastapi==0.115.6
+json5==0.14.0
+openai==2.3.0
+Pillow==11.3.0
+requests==2.32.5
+structai==0.1.22
+tiktoken==0.12.0
+uvicorn==0.34.0
diff --git a/run_agent.py b/run_agent.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ba72187473890cbd6ec41a7b09606b13462b199
--- /dev/null
+++ b/run_agent.py
@@ -0,0 +1,7 @@
+"""Thin top-level CLI entrypoint for the ResearchHarness agent."""
+
+from agent_base.react_agent import main
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/run_frontend.py b/run_frontend.py
new file mode 100644
index 0000000000000000000000000000000000000000..6528d113bf82d311b20928959375d3206056f54f
--- /dev/null
+++ b/run_frontend.py
@@ -0,0 +1,48 @@
+"""Launch the local ResearchHarness browser UI."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+import threading
+import webbrowser
+
+import uvicorn
+
+from agent_base.utils import read_role_prompt_files
+from frontend.local_server import app, configure_frontend
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Run the local ResearchHarness frontend.")
+ parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1")
+ parser.add_argument("--port", type=int, default=8765, help="Port to bind. Default: 8765")
+ parser.add_argument("--no-browser", action="store_true", help="Do not open the browser automatically.")
+ parser.add_argument("--trace-dir", help="Optional directory where frontend agent traces are written.")
+ parser.add_argument(
+ "--role-prompt-file",
+ action="append",
+ default=[],
+ dest="role_prompt_files",
+ metavar="PATH",
+ help="Append one role-specific prompt file to the frontend agent. May be passed multiple times.",
+ )
+ args = parser.parse_args(argv)
+
+ try:
+ role_prompt = read_role_prompt_files(args.role_prompt_files)
+ configure_frontend(role_prompt=role_prompt, trace_dir=args.trace_dir)
+ except (OSError, ValueError) as exc:
+ print(str(exc), file=sys.stderr)
+ return 1
+
+ url = f"http://{args.host}:{args.port}"
+ if not args.no_browser:
+ threading.Timer(0.8, lambda: webbrowser.open(url)).start()
+ print(f"ResearchHarness frontend: {url}")
+ uvicorn.run(app, host=args.host, port=args.port, reload=False)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/run_server.py b/run_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8e6eaf311854559c7b023629bce45a56ae976df
--- /dev/null
+++ b/run_server.py
@@ -0,0 +1,61 @@
+"""Run ResearchHarness as a minimal OpenAI-compatible API server."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+
+from agent_base.utils import PROJECT_ROOT, MissingRequiredEnvError, load_dotenv, require_required_env
+from api.openai_server import serve
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Serve ResearchHarness through /v1/chat/completions.")
+ parser.add_argument(
+ "--api-runs-dir",
+ required=True,
+ dest="api_runs_dir",
+ help="Directory where the server creates one isolated subdirectory per request.",
+ )
+ parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Defaults to 127.0.0.1.")
+ parser.add_argument("--port", type=int, default=8686, help="Port to bind. Defaults to 8686.")
+ parser.add_argument(
+ "--role-prompt-file",
+ action="append",
+ default=[],
+ dest="role_prompt_files",
+ help="Optional role prompt file appended to the base ResearchHarness prompt.",
+ )
+ parser.add_argument(
+ "--input-wrapper",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ help="Enable or disable the input LLM wrapper. Enabled by default.",
+ )
+ parser.add_argument(
+ "--output-wrapper",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ help="Enable or disable the output LLM wrapper. Enabled by default.",
+ )
+ args = parser.parse_args(argv)
+
+ load_dotenv(PROJECT_ROOT / ".env")
+ try:
+ require_required_env("ResearchHarness API server")
+ serve(
+ api_runs_dir=args.api_runs_dir,
+ host=args.host,
+ port=args.port,
+ role_prompt_files=list(args.role_prompt_files),
+ input_wrapper=args.input_wrapper,
+ output_wrapper=args.output_wrapper,
+ )
+ except (MissingRequiredEnvError, ValueError) as exc:
+ print(str(exc), file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/traces/.gitkeep b/traces/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc
--- /dev/null
+++ b/traces/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/workspace/.gitkeep b/workspace/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc
--- /dev/null
+++ b/workspace/.gitkeep
@@ -0,0 +1 @@
+