Restructure web app directories

This commit is contained in:
武阳
2026-05-06 12:01:22 +08:00
parent 2ea3a63447
commit 4e9ea0e3bb
12 changed files with 22 additions and 12 deletions
+2 -1
View File
@@ -9,8 +9,9 @@ from pathlib import Path
import uvicorn
from backend.api.server import AgentState, create_app
from ..session_store import DEFAULT_AGENT_SESSION_DIR
from .server import AgentState, create_app
def main() -> None:
-340
View File
@@ -1,340 +0,0 @@
"""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(state.cwd)
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'),
}
-719
View File
@@ -1,719 +0,0 @@
: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;
}
}
-616
View File
@@ -1,616 +0,0 @@
"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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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, "<h3>$1</h3>");
text = text.replace(/^## (.+)$/gm, "<h2>$1</h2>");
text = text.replace(/^# (.+)$/gm, "<h1>$1</h1>");
// Bold / italic
text = text.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
text = text.replace(/(^|\W)\*([^*\n]+)\*(\W|$)/g, "$1<em>$2</em>$3");
// Inline code
text = text.replace(/`([^`\n]+)`/g, "<code>$1</code>");
// Links
text = text.replace(
/\[([^\]]+)\]\(([^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>'
);
// Lists (simple, line-based)
text = text.replace(/(?:^|\n)((?:- .+\n?)+)/g, (_, block) => {
const items = block
.trim()
.split(/\n/)
.map((l) => `<li>${l.replace(/^- /, "")}</li>`)
.join("");
return `\n<ul>${items}</ul>\n`;
});
text = text.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (_, block) => {
const items = block
.trim()
.split(/\n/)
.map((l) => `<li>${l.replace(/^\d+\. /, "")}</li>`)
.join("");
return `\n<ol>${items}</ol>\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 `<p>${t.replace(/\n/g, "<br/>")}</p>`;
})
.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 `<pre><code${langClass}>${escapeHtml(body)}</code></pre>`;
});
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 = `<div class="avatar">⚙</div>`;
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 = `
<span class="tool-name">${escapeHtml(name)}</span>
<span class="tool-args">${escapeHtml(argsPreview).slice(0, 240)}</span>
`;
details.appendChild(summary);
const inner = document.createElement("div");
inner.className = "tool-body";
inner.innerHTML = `
<div class="label">Arguments</div>
<pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>
<div class="label">Result${isError ? " (error)" : ""}</div>
<pre>${escapeHtml(result || "")}</pre>
`;
details.appendChild(inner);
body.appendChild(details);
wrap.appendChild(body);
els.chat.appendChild(wrap);
els.chat.scrollTop = els.chat.scrollHeight;
}
function parseAssistantToolCalls(entry) {
if (!Array.isArray(entry?.tool_calls)) return [];
return entry.tool_calls.map((tc) => {
const fn = tc?.function || {};
let args = fn.arguments ?? tc?.arguments ?? "";
try {
if (typeof args === "string") args = JSON.parse(args);
} catch {}
return {
id: tc?.id || "",
name: fn.name || tc?.name || "tool",
args,
};
});
}
function renderTranscriptEntries(entries) {
const pendingToolCalls = new Map();
for (const entry of entries || []) {
if (!entry || entry.role === "system") continue;
if (entry.role === "assistant") {
if (entry.content && entry.content.trim()) {
appendMessage({ role: "assistant", content: entry.content });
}
for (const call of parseAssistantToolCalls(entry)) {
if (call.id) pendingToolCalls.set(call.id, call);
}
continue;
}
if (entry.role === "tool") {
const call = pendingToolCalls.get(entry.tool_call_id) || {};
let parsedResult = null;
try {
parsedResult = JSON.parse(entry.content || "{}");
} catch {}
appendToolCall({
name: call.name || entry.name || parsedResult?.tool || "tool",
args: call.args ?? "",
result: entry.content || "",
isError: parsedResult?.ok === false || entry.metadata?.error_kind,
});
if (entry.tool_call_id) pendingToolCalls.delete(entry.tool_call_id);
continue;
}
renderTranscriptEntry(entry);
}
for (const call of pendingToolCalls.values()) {
appendToolCall({
name: call.name,
args: call.args,
result: "(pending — no tool result message found)",
});
}
}
// ---------------------------------------------------------------------------
// 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 = `<div class="empty-state">${escapeHtml(e.message)}</div>`;
}
}
function renderSessions() {
els.sessionList.innerHTML = "";
if (!State.sessions.length) {
els.sessionList.innerHTML = `<div class="empty-state">No saved sessions yet.</div>`;
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 = `
<div class="session-preview">${escapeHtml(preview)}</div>
<div class="session-meta">${s.turns} turns · ${s.tool_calls} tools</div>
`;
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();
renderTranscriptEntries(session.messages);
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 = `<span class="palette-name">${escapeHtml(display.name)}</span><span class="palette-desc">${escapeHtml(display.desc || "")}</span>`;
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 = `<div class="empty-state" style="padding:10px;">No matches.</div>`;
}
}
// ---------------------------------------------------------------------------
// 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);
renderTranscriptEntries(tail);
// transcript 通常已经包含最终 assistant 消息;只有缺失时才用 final_output 兜底。
const finalOutput = (data.final_output || "").trim();
const lastAssistant = [...tail]
.reverse()
.find((entry) => entry?.role === "assistant" && (entry.content || "").trim());
const lastAssistantContent = (lastAssistant?.content || "").trim();
if (finalOutput && finalOutput !== lastAssistantContent) {
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();
-108
View File
@@ -1,108 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Claw Code</title>
<link rel="stylesheet" href="/static/app.css" />
</head>
<body>
<div id="app">
<aside class="sidebar">
<div class="sidebar-head">
<div class="brand">
<span class="brand-mark">CC</span>
<span class="brand-name">Claw&nbsp;Code</span>
</div>
<button id="new-session-btn" class="btn-primary" type="button">+ New chat</button>
</div>
<div class="sidebar-section">
<div class="sidebar-title">Sessions</div>
<div id="session-list" class="session-list">
<div class="empty-state">Loading…</div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-title">Settings</div>
<form id="settings-form" class="settings">
<label>
<span>Model</span>
<input type="text" name="model" autocomplete="off" />
</label>
<label>
<span>Base URL</span>
<input type="text" name="base_url" autocomplete="off" />
</label>
<label>
<span>Working dir</span>
<input type="text" name="cwd" autocomplete="off" />
</label>
<label class="toggle">
<input type="checkbox" name="allow_shell" />
<span>Allow shell commands</span>
</label>
<label class="toggle">
<input type="checkbox" name="allow_write" />
<span>Allow write/edit</span>
</label>
<button type="submit" class="btn-secondary">Save</button>
</form>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="topbar-left">
<span class="status-dot" id="status-dot"></span>
<span id="status-text">Ready</span>
</div>
<div class="topbar-right">
<button id="slash-btn" class="btn-ghost" type="button" title="Slash commands">/</button>
<button id="skills-btn" class="btn-ghost" type="button" title="Skills"></button>
<button id="clear-btn" class="btn-ghost" type="button" title="Clear runtime state"></button>
</div>
</header>
<section id="chat" class="chat">
<div id="welcome" class="welcome">
<h1>Claw Code</h1>
<p>Type a message below to start. Use the <code>/</code> button for slash commands or <code></code> for skills.</p>
</div>
</section>
<footer class="composer">
<div class="composer-inner">
<textarea
id="prompt-input"
placeholder="Send a message… (Enter to send, Shift+Enter for newline)"
rows="1"
autocomplete="off"
></textarea>
<button id="send-btn" type="button" class="btn-send" title="Send">Send</button>
</div>
<div class="composer-meta">
<span id="cwd-meta"></span>
<span id="usage-meta"></span>
</div>
</footer>
</main>
</div>
<div id="palette" class="palette hidden">
<div class="palette-card">
<div class="palette-head">
<input
id="palette-search"
type="text"
placeholder="Filter…"
autocomplete="off"
/>
<button class="btn-ghost" id="palette-close" type="button"></button>
</div>
<div id="palette-list" class="palette-list"></div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>