Add custom web server and startup script

This commit is contained in:
gyt
2026-07-09 13:29:20 -04:00
parent f0bdd26b08
commit 0a99bb74bd
3 changed files with 1243 additions and 0 deletions
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# OpenDAN startup script - tuned for 24-core / 62GB RAM system
DIR="$(cd "$(dirname "$0")" && pwd)"
VENV="$DIR/venv"
REPO="$DIR/repo"
export AIOS_ROOT="$REPO/rootfs"
export AIOS_MYAI="$DIR/myai"
export PYTHONPATH="$REPO/src:$REPO/src/component:$REPO/src/service"
# System tuning for 24-core Xeon E5-2430
export OMP_NUM_THREADS=20
export MKL_NUM_THREADS=20
export NUMEXPR_NUM_THREADS=20
export OPENBLAS_NUM_THREADS=20
export VECLIB_MAXIMUM_THREADS=20
mkdir -p "$AIOS_MYAI"
echo "Starting OpenDAN..."
echo " AIOS_ROOT: $AIOS_ROOT"
echo " AIOS_MYAI: $AIOS_MYAI"
echo " OMP_NUM_THREADS: $OMP_NUM_THREADS"
echo " Python: $(python3 --version)"
echo ""
cd "$REPO/src/service/aios_shell"
"$VENV/bin/python" aios_shell.py "$@"
+575
View File
@@ -0,0 +1,575 @@
import asyncio, json, os, subprocess, sys, tempfile, re, textwrap
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse, quote
import uvicorn
import aiohttp
import aiofiles
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
app = FastAPI(title="OpenDAN Web")
LLAMA_CHAT = "http://127.0.0.1:8081"
LLAMA_EMBED = "http://127.0.0.1:8082"
MYAI = Path.home() / "myai"
MAX_HISTORY = 50
conversations: dict[str, list[dict]] = {}
proposals: dict[str, dict] = {}
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "system.shell.exec",
"description": "Execute a shell command in Linux bash. Use for running programs, system administration, file operations.",
"parameters": {
"type": "object",
"properties": {"command": {"type": "string", "description": "The bash command to execute"}},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "system.code_interpreter",
"description": "Execute Python code in an isolated environment. Use for data analysis, calculations, scripting.",
"parameters": {
"type": "object",
"properties": {"code": {"type": "string", "description": "Python code to execute"}},
"required": ["code"]
}
}
},
{
"type": "function",
"function": {
"name": "agent.workspace.read_file",
"description": "Read a file from the user's filesystem.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Absolute path to the file"}},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "agent.workspace.write_file",
"description": "Write content to a file. Creates parent directories if needed.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute path to the file"},
"content": {"type": "string", "description": "Content to write"}
},
"required": ["path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "agent.workspace.list_dir",
"description": "List files and directories in a given path.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Directory path to list"}},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "web.search",
"description": "Search the web using DuckDuckGo. Use for finding current information.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "description": "Search query"}},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "system.now",
"description": "Get the current date and time.",
"parameters": {"type": "object", "properties": {}}
}
},
]
TOOL_IMPLS = {}
async def tool_shell_exec(command: str) -> dict:
try:
proc = await asyncio.create_subprocess_shell(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(MYAI))
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
out = stdout.decode(errors="replace").strip()
err = stderr.decode(errors="replace").strip()
return {"status": "success" if proc.returncode == 0 else "error",
"stdout": out[:10000], "stderr": err[:5000],
"returncode": proc.returncode}
except asyncio.TimeoutError:
return {"status": "error", "error": "Command timed out after 30s"}
except Exception as e:
return {"status": "error", "error": str(e)}
async def tool_code_interpreter(code: str) -> dict:
tmp_dir = MYAI / "tmp_code"
tmp_dir.mkdir(parents=True, exist_ok=True)
script = tmp_dir / "_web_exec.py"
async with aiofiles.open(script, "w") as f:
await f.write(code)
try:
proc = await asyncio.create_subprocess_exec(
sys.executable, str(script),
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(tmp_dir))
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
out = stdout.decode(errors="replace").strip()
err = stderr.decode(errors="replace").strip()
return {"status": "success" if proc.returncode == 0 else "error",
"stdout": out[:10000], "stderr": err[:5000],
"returncode": proc.returncode}
except asyncio.TimeoutError:
return {"status": "error", "error": "Execution timed out after 30s"}
except Exception as e:
return {"status": "error", "error": str(e)}
finally:
if script.exists():
script.unlink()
async def tool_read_file(path: str) -> dict:
p = Path(path).expanduser().resolve()
if not p.exists():
return {"status": "error", "error": f"File not found: {path}"}
if not p.is_file():
return {"status": "error", "error": f"Not a file: {path}"}
try:
async with aiofiles.open(p, "r") as f:
content = await f.read()
return {"status": "success", "content": content[:50000]}
except Exception as e:
return {"status": "error", "error": str(e)}
async def tool_write_file(path: str, content: str) -> dict:
p = Path(path).expanduser().resolve()
p.parent.mkdir(parents=True, exist_ok=True)
try:
async with aiofiles.open(p, "w") as f:
await f.write(content)
return {"status": "success", "path": str(p)}
except Exception as e:
return {"status": "error", "error": str(e)}
async def tool_list_dir(path: str) -> dict:
p = Path(path).expanduser().resolve()
if not p.exists():
return {"status": "error", "error": f"Path not found: {path}"}
if not p.is_dir():
return {"status": "error", "error": f"Not a directory: {path}"}
try:
items = []
for entry in sorted(p.iterdir()):
items.append({"name": entry.name, "type": "dir" if entry.is_dir() else "file",
"size": entry.stat().st_size if entry.is_file() else 0})
return {"status": "success", "path": str(p), "items": items}
except Exception as e:
return {"status": "error", "error": str(e)}
async def tool_web_search(query: str) -> dict:
try:
loop = asyncio.get_event_loop()
results = await loop.run_in_executor(None, _search_ddg, query)
return {"status": "success", "query": query, "results": results}
except Exception as e:
return {"status": "error", "error": str(e)}
def _search_ddg(query: str, max_results: int = 5) -> list[dict]:
from curl_cffi import requests
from bs4 import BeautifulSoup
r = requests.get(
"https://lite.duckduckgo.com/lite/",
params={"q": query, "kp": "-2"},
impersonate="chrome",
timeout=15
)
soup = BeautifulSoup(r.text, "lxml")
rows = soup.select("tr")
out = []
i = 0
while i < len(rows) and len(out) < max_results:
a = rows[i].select_one("a.result-link")
if not a:
i += 1
continue
title = a.get_text(strip=True)
raw_url = a.get("href", "")
snippet = ""
if i + 1 < len(rows):
snip_td = rows[i + 1].select_one("td.result-snippet")
if snip_td:
snippet = snip_td.get_text(strip=True)
out.append({"title": title[:200], "snippet": snippet[:500], "url": raw_url[:300]})
i += 3
if not out:
out.append({"title": "No results", "snippet": f"No search results for: {query}", "url": ""})
return out
async def tool_system_now() -> dict:
from datetime import datetime
return {"status": "success", "datetime": datetime.now().isoformat()}
TOOL_IMPLS = {
"system.shell.exec": tool_shell_exec,
"system.code_interpreter": tool_code_interpreter,
"agent.workspace.read_file": tool_read_file,
"agent.workspace.write_file": tool_write_file,
"agent.workspace.list_dir": tool_list_dir,
"web.search": tool_web_search,
"system.now": tool_system_now,
}
async def call_llm(messages: list[dict], tools: list[dict] = None,
tool_choice: str = "auto", model: str = None,
max_tokens: int = 4096, temperature: float = 0.7) -> dict:
body = {"messages": messages, "max_tokens": max_tokens, "temperature": temperature}
if model:
body["model"] = model
if tools:
body["tools"] = tools
body["tool_choice"] = tool_choice
async with aiohttp.ClientSession() as sess:
async with sess.post(f"{LLAMA_CHAT}/v1/chat/completions",
json=body, timeout=aiohttp.ClientTimeout(total=120)) as resp:
if resp.status != 200:
txt = await resp.text()
raise HTTPException(status_code=resp.status, detail=txt[:500])
return await resp.json()
async def stream_llm(messages: list[dict], tools: list[dict] = None,
tool_choice: str = "auto", model: str = None):
body = {"messages": messages, "max_tokens": 4096, "temperature": 0.7,
"stream": True}
if model:
body["model"] = model
if tools:
body["tools"] = tools
body["tool_choice"] = tool_choice
async with aiohttp.ClientSession() as sess:
async with sess.post(f"{LLAMA_CHAT}/v1/chat/completions",
json=body, timeout=aiohttp.ClientTimeout(total=120)) as resp:
if resp.status != 200:
yield f"data: {json.dumps({'type': 'error', 'detail': await resp.text()})}\n\n"
return
buffer = ""
async for chunk in resp.content.iter_chunks():
data = chunk[0].decode(errors="replace")
buffer += data
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
if line.startswith("data: "):
payload = line[6:]
if payload == "[DONE]":
yield f"data: {json.dumps({'type': 'done'})}\n\n"
return
try:
obj = json.loads(payload)
yield f"data: {json.dumps({'type': 'token', 'data': obj})}\n\n"
except json.JSONDecodeError:
pass
def get_thread(thread_id: str) -> list[dict]:
if thread_id not in conversations:
conversations[thread_id] = [
{"role": "system", "content": textwrap.dedent("""\
You are an AI assistant with full system access.
Use tools to answer questions requiring data or actions.
Never describe what tools you would use — just use them.
For simple greetings or chat, reply directly without tools.
After your final answer, suggest 3 brief follow-up questions
the user might want to ask next. Wrap them as a JSON object
on its own line: {"suggestions": ["q1?", "q2?", "q3?"]}.
""").strip()}
]
return conversations[thread_id]
def trim_thread(thread: list[dict]):
if len(thread) > MAX_HISTORY:
system = [m for m in thread if m["role"] == "system"]
rest = [m for m in thread if m["role"] != "system"]
thread[:] = system + rest[-(MAX_HISTORY - len(system)):]
@app.get("/health")
async def health():
async with aiohttp.ClientSession() as sess:
try:
async with sess.get(f"{LLAMA_CHAT}/health",
timeout=aiohttp.ClientTimeout(total=3)) as r:
chat = r.status == 200
except:
chat = False
try:
async with sess.get(f"{LLAMA_EMBED}/health",
timeout=aiohttp.ClientTimeout(total=3)) as r:
embed = r.status == 200
except:
embed = False
return {"status": "ok", "chat": chat, "embeddings": embed, "tools": list(TOOL_IMPLS.keys())}
@app.get("/", response_class=HTMLResponse)
async def index():
p = Path(__file__).parent / "templates" / "index.html"
return HTMLResponse(p.read_text())
@app.post("/v1/chat")
async def chat(request: Request):
body = await request.json()
msg = body.get("message", "").strip()
thread_id = body.get("thread_id", "default")
if not msg:
raise HTTPException(400, "message is required")
thread = get_thread(thread_id)
thread.append({"role": "user", "content": msg})
trim_thread(thread)
return await _process_tools(thread, thread_id)
@app.post("/v1/chat/stream")
async def chat_stream(request: Request):
body = await request.json()
thread_id = body.get("thread_id", "default")
proposal_id = body.get("proposal_id")
decisions = body.get("decisions")
if proposal_id and decisions is not None:
return StreamingResponse(_execute_proposal_stream(proposal_id, decisions, thread_id),
media_type="text/event-stream")
msg = body.get("message", "").strip()
if not msg:
raise HTTPException(400, "message is required")
thread = get_thread(thread_id)
thread.append({"role": "user", "content": msg})
trim_thread(thread)
return StreamingResponse(_process_stream(thread, thread_id),
media_type="text/event-stream")
@app.delete("/v1/conversation/{thread_id}")
async def clear_conversation(thread_id: str):
conversations.pop(thread_id, None)
return {"status": "ok"}
@app.get("/v1/tools")
async def list_tools():
return TOOL_DEFINITIONS
@app.post("/v1/chat/propose")
async def chat_propose(request: Request):
body = await request.json()
msg = body.get("message", "").strip()
thread_id = body.get("thread_id", "default")
if not msg:
raise HTTPException(400, "message is required")
thread = get_thread(thread_id)
temp_msgs = thread + [{"role": "user", "content": msg}]
resp = await call_llm(temp_msgs, tools=TOOL_DEFINITIONS, tool_choice="auto")
msg_obj = resp["choices"][0]["message"]
tool_calls = msg_obj.get("tool_calls")
if not tool_calls:
thread.append({"role": "user", "content": msg})
content = msg_obj.get("content", "") or ""
thread.append({"role": "assistant", "content": content})
trim_thread(thread)
return {"response": content, "thread_id": thread_id, "tool_calls": []}
pid = f"{thread_id}_{len(thread)}_{int(asyncio.get_event_loop().time()*1000000)}"
raw = []
for tc in tool_calls:
try:
a = json.loads(tc["function"]["arguments"])
except:
a = {}
raw.append({"tool_call_id": tc["id"], "name": tc["function"]["name"], "args": a})
proposals[pid] = {
"thread_id": thread_id, "user_content": msg,
"assistant_content": msg_obj.get("content") or "",
"tool_calls": tool_calls,
}
return {"proposal_id": pid, "tool_calls": raw, "content": msg_obj.get("content") or "",
"thread_id": thread_id}
async def _process_tools(thread: list[dict], thread_id: str, depth: int = 0):
if depth > 5:
return {"response": "Tool call depth exceeded (max 5).", "thread_id": thread_id}
resp = await call_llm(thread, tools=TOOL_DEFINITIONS, tool_choice="auto")
msg = resp["choices"][0]["message"]
tool_calls = msg.get("tool_calls")
if tool_calls:
thread.append({"role": "assistant", "content": msg.get("content") or "",
"tool_calls": tool_calls})
results = []
for tc in tool_calls:
fn = tc["function"]
name = fn["name"]
try:
args = json.loads(fn["arguments"])
except:
args = {}
impl = TOOL_IMPLS.get(name)
if impl:
result = await impl(**args)
else:
result = {"status": "error", "error": f"Unknown tool: {name}"}
results.append({"tool_call_id": tc["id"], "name": name, "result": result})
thread.append({"role": "tool", "tool_call_id": tc["id"],
"content": json.dumps(result)})
trim_thread(thread)
final = await _process_tools(thread, thread_id, depth + 1)
final["tool_results"] = results
return final
else:
content = msg.get("content", "") or ""
thread.append({"role": "assistant", "content": content})
trim_thread(thread)
return {"response": content, "thread_id": thread_id}
async def _process_stream(thread: list[dict], thread_id: str, depth: int = 0):
if depth > 5:
yield f"data: {json.dumps({'type': 'error', 'detail': 'Tool call depth exceeded (max 5)'})}\n\n"
return
collected_content = ""
collected_tool_calls = None
async for event in stream_llm(thread, tools=TOOL_DEFINITIONS, tool_choice="auto"):
yield event
try:
data = json.loads(event[6:].strip()) if event.startswith("data: ") else None
except:
data = None
if data is None:
continue
if data.get("type") == "done":
break
obj = data.get("data", {})
delta = obj.get("choices", [{}])[0].get("delta", {})
collected_content += delta.get("content", "") or ""
if "tool_calls" in delta:
if collected_tool_calls is None:
collected_tool_calls = []
for tc in delta["tool_calls"]:
idx = tc.get("index", len(collected_tool_calls))
while len(collected_tool_calls) <= idx:
collected_tool_calls.append({"id": "", "function": {"name": "", "arguments": ""}})
if tc.get("type"):
collected_tool_calls[idx]["type"] = tc["type"]
if tc.get("id"):
collected_tool_calls[idx]["id"] = tc["id"]
if tc.get("function", {}).get("name"):
collected_tool_calls[idx]["function"]["name"] += tc["function"]["name"]
if tc.get("function", {}).get("arguments"):
collected_tool_calls[idx]["function"]["arguments"] += tc["function"]["arguments"]
if collected_tool_calls:
msg = {"role": "assistant", "content": collected_content,
"tool_calls": collected_tool_calls}
thread.append(msg)
results = []
for tc in collected_tool_calls:
fn = tc["function"]
name = fn["name"]
try:
args = json.loads(fn["arguments"])
except:
args = {}
impl = TOOL_IMPLS.get(name)
yield f"data: {json.dumps({'type': 'tool_start', 'name': name, 'args': args})}\n\n"
if impl:
result = await impl(**args)
else:
result = {"status": "error", "error": f"Unknown tool: {name}"}
results.append({"tool_call_id": tc["id"], "name": name, "result": result})
yield f"data: {json.dumps({'type': 'tool_result', 'name': name, 'result': result})}\n\n"
thread.append({"role": "tool", "tool_call_id": tc["id"],
"content": json.dumps(result)})
trim_thread(thread)
async for event in _process_stream(thread, thread_id, depth + 1):
yield event
else:
if collected_content:
display, suggestions = _extract_suggestions(collected_content)
if display != collected_content:
yield f"data: {json.dumps({'type': 'suggestions', 'suggestions': suggestions})}\n\n"
thread.append({"role": "assistant", "content": collected_content})
trim_thread(thread)
def _extract_suggestions(text: str) -> tuple[str, list[str]]:
m = re.search(r'\{\s*"suggestions"\s*:\s*\[(.*?)\]\s*\}', text, re.DOTALL)
if not m:
return text, []
try:
obj = json.loads("{" + m.group(0) + "}")
suggestions = obj.get("suggestions", [])[:3]
rest = text[:m.start()].rstrip() + text[m.end():]
return rest, suggestions
except:
return text, []
async def _execute_proposal_stream(proposal_id: str, decisions: list[dict], thread_id: str):
if proposal_id not in proposals:
yield f"data: {json.dumps({'type': 'error', 'detail': 'Proposal not found'})}\n\n"
return
prop = proposals.pop(proposal_id)
thread = get_thread(thread_id)
thread.append({"role": "user", "content": prop["user_content"]})
tool_calls = prop["tool_calls"]
assistant_content = prop["assistant_content"]
thread.append({"role": "assistant", "content": assistant_content, "tool_calls": tool_calls})
decision_map = {d["tool_call_id"]: d.get("approved", False) for d in decisions}
for tc in tool_calls:
tc_id = tc["id"]
fn = tc["function"]
name = fn["name"]
try:
args = json.loads(fn["arguments"]) if isinstance(fn["arguments"], str) else fn["arguments"]
except:
args = {}
yield f"data: {json.dumps({'type': 'tool_start', 'name': name, 'args': args, 'tool_call_id': tc_id})}\n\n"
if decision_map.get(tc_id, False):
impl = TOOL_IMPLS.get(name)
if impl:
result = await impl(**args)
else:
result = {"status": "error", "error": f"Unknown tool: {name}"}
else:
result = {"status": "skipped", "reason": "Rejected by user"}
yield f"data: {json.dumps({'type': 'tool_result', 'name': name, 'result': result, 'tool_call_id': tc_id})}\n\n"
thread.append({"role": "tool", "tool_call_id": tc_id, "content": json.dumps(result)})
trim_thread(thread)
collected_content = ""
async for event in stream_llm(thread, tools=None):
yield event
try:
data = json.loads(event[6:].strip()) if event.startswith("data: ") else None
except:
data = None
if data and data.get("type") == "done":
pass
elif data and data.get("type") == "token":
delta = data.get("data", {}).get("choices", [{}])[0].get("delta", {})
collected_content += delta.get("content", "") or ""
if collected_content:
display, suggestions = _extract_suggestions(collected_content)
if display != collected_content:
yield f"data: {json.dumps({'type': 'suggestions', 'suggestions': suggestions})}\n\n"
thread.append({"role": "assistant", "content": collected_content})
trim_thread(thread)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8080)
+640
View File
@@ -0,0 +1,640 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenDAN Web</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
.header { background: #16213e; padding: 12px 20px; display: flex; align-items: center;
justify-content: space-between; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.header h1 { font-size: 18px; color: #e94560; }
.header .status { font-size: 12px; color: #888; display: flex; align-items: center; gap: 6px; }
.header .status .dot { width: 8px; height: 8px; border-radius: 50%; }
.header .status .dot.online { background: #4ecca3; }
.header .status .dot.offline { background: #e94560; }
.header .status .dot.busy { background: #f0c040; animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.chat { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 85%; padding: 10px 14px; border-radius: 12px; line-height: 1.6;
font-size: 14px; animation: fadeIn .2s; overflow-wrap: break-word; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.msg.user { background: #0f3460; align-self: flex-end; border-bottom-right-radius: 4px; }
.msg.assistant { background: #16213e; align-self: flex-start; border-bottom-left-radius: 4px; }
.msg.assistant p { margin-bottom: 8px; }
.msg.assistant p:last-child { margin-bottom: 0; }
.msg.assistant code { background: #0a0a1a; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
.msg.assistant pre { background: #0a0a1a; padding: 10px; border-radius: 6px; overflow-x: auto;
margin: 8px 0; border: 1px solid #222; }
.msg.assistant pre code { background: none; padding: 0; font-size: 12px; }
.msg.assistant ul, .msg.assistant ol { padding-left: 20px; margin: 6px 0; }
.msg.assistant strong { color: #fff; }
.msg.assistant blockquote { border-left: 3px solid #e94560; padding-left: 10px; margin: 6px 0; color: #aaa; }
/* Tool execution cards */
.tool-group { border: 1px solid #333; border-radius: 8px; padding: 8px 12px; font-size: 12px;
font-family: monospace; background: #111; max-width: 100%; }
.tool-group .tool-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.tool-group .tool-header .tool-icon { font-size: 14px; }
.tool-group .tool-call { color: #e94560; font-weight: bold; }
.tool-group .tool-args { color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex:1; }
.tool-group .tool-status { margin-top: 4px; display: flex; align-items: center; gap: 6px; }
.tool-group .tool-status .spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #555;
border-top-color: #4ecca3; border-radius: 50%; animation: spin .6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.tool-group .tool-result-text { margin-top: 4px; background: #0a0a1a; padding: 6px 8px; border-radius: 4px;
max-height: 150px; overflow: auto; white-space: pre-wrap; font-size: 11px; color: #4ecca3; }
.tool-group .tool-result-text.error { color: #e94560; }
.tool-group .tool-result-text.skipped { color: #888; font-style: italic; }
/* Proposal cards */
.proposal-card { border: 1px solid #e9456044; border-radius: 10px; padding: 10px 14px;
background: #1a1a2e; max-width: 100%; animation: fadeIn .3s; }
.proposal-card .pc-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.proposal-card .pc-header .pc-icon { color: #f0c040; font-size: 16px; }
.proposal-card .pc-name { color: #e94560; font-weight: bold; font-family: monospace; font-size: 13px; }
.proposal-card .pc-args { background: #0a0a1a; border-radius: 4px; padding: 6px 8px;
font-family: monospace; font-size: 11px; max-height: 100px;
overflow: auto; white-space: pre-wrap; margin: 4px 0; color: #aaa; }
.proposal-card .pc-actions { display: flex; gap: 8px; margin-top: 8px; }
.proposal-card .pc-actions button { padding: 4px 14px; border-radius: 6px; border: none;
cursor: pointer; font-size: 12px; font-weight: bold; }
.proposal-card .pc-actions .btn-approve { background: #2a6e3c; color: #4ecca3; }
.proposal-card .pc-actions .btn-approve:hover { background: #3a8e4c; }
.proposal-card .pc-actions .btn-approve.active { background: #4ecca3; color: #111; }
.proposal-card .pc-actions .btn-reject { background: #6e2a2a; color: #e94560; }
.proposal-card .pc-actions .btn-reject:hover { background: #8e3a3a; }
.proposal-card .pc-actions .btn-reject.active { background: #e94560; color: #fff; }
.proposal-card .pc-actions .btn-approve:disabled, .proposal-card .pc-actions .btn-reject:disabled { opacity: .4; cursor: not-allowed; }
/* Run bar */
.run-bar { display: flex; align-items: center; gap: 10px; background: #16213e; border: 1px solid #0f3460;
border-radius: 10px; padding: 10px 16px; max-width: 100%; }
.run-bar .rb-text { font-size: 13px; color: #aaa; flex:1; }
.run-bar .rb-btn { background: #e94560; color: white; border: none; border-radius: 8px;
padding: 8px 20px; cursor: pointer; font-weight: bold; font-size: 13px; }
.run-bar .rb-btn:disabled { opacity: .4; cursor: not-allowed; }
.run-bar .rb-btn:hover:not(:disabled) { background: #d63851; }
/* Toolbar */
.toolbar { background: #16213e; padding: 6px 20px; border-top: 1px solid #0f3460;
display: flex; gap: 4px; flex-wrap: wrap; flex-shrink: 0; }
.toolbar .tbtn { background: #111a2e; border: 1px solid #333; border-radius: 6px;
padding: 3px 10px; cursor: pointer; font-size: 11px; color: #aaa;
display: flex; align-items: center; gap: 4px; transition: all .1s; }
.toolbar .tbtn:hover { border-color: #e94560; color: #e0e0e0; background: #1a1a3e; }
.toolbar .tbtn:active { transform: scale(.95); }
.toolbar .tbtn .ti { font-size: 13px; }
/* Thinking bar */
.think-bar { display: none; align-items: center; gap: 8px; background: #111a2e;
border: 1px solid #0f3460; border-radius: 8px; padding: 8px 14px;
font-size: 12px; color: #aaa; animation: fadeIn .15s; cursor: default;
position: sticky; bottom: 0; z-index: 10; margin-top: auto; }
.think-bar.visible { display: flex; }
.think-bar .tb-spinner { width: 12px; height: 12px; border: 2px solid #555;
border-top-color: #f0c040; border-radius: 50%;
animation: spin .6s linear infinite; flex-shrink: 0; }
.think-bar .tb-label { color: #f0c040; font-weight: bold; }
.think-bar .tb-detail { color: #888; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; flex: 1; min-width: 0; }
.think-bar .tb-hint { color: #555; font-size: 10px; flex-shrink: 0; }
/* Hover popup */
.think-popup { display: none; position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%);
width: min(90vw, 700px); max-height: 40vh; background: #0a0a1a;
border: 1px solid #0f3460; border-radius: 10px; padding: 14px;
font-family: monospace; font-size: 12px; color: #ccc; white-space: pre-wrap;
overflow: auto; z-index: 100; box-shadow: 0 8px 30px rgba(0,0,0,.5); }
.think-popup.visible { display: block; }
.think-popup .tp-label { color: #f0c040; font-size: 10px; text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 8px; }
.think-popup .tp-tool { color: #e94560; font-weight: bold; }
/* Suggestion chips */
.suggestions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; max-width: 85%; align-self: flex-start; }
.suggestions .chip { background: #0f3460; color: #aac; border: 1px solid #1f4470; border-radius: 16px;
padding: 6px 14px; font-size: 12px; cursor: pointer;
transition: all .15s; }
.suggestions .chip:hover { background: #1f4470; border-color: #e94560; color: #e0e0e0; }
.suggestions .chip:active { transform: scale(.96); }
/* Msg footer */
.msg { position: relative; }
.msg-content { min-height: 1em; }
.msg-footer { display: flex; gap: 4px; justify-content: flex-end; margin-top: 6px;
opacity: 0; transition: opacity .15s; }
.msg:hover .msg-footer { opacity: .4; }
.msg-footer:hover { opacity: 1 !important; }
.action-btn { background: none; border: 1px solid #444; border-radius: 4px; color: #888;
cursor: pointer; font-size: 11px; padding: 0 6px; line-height: 20px; }
.action-btn:hover { border-color: #e94560; color: #e94560; background: #1a1a3e; }
.cancel-btn { border-color: #e9456044; color: #e94560; }
.cancel-btn:hover { border-color: #e94560; background: #2a1a1e; }
/* Input */
.input-area { background: #16213e; padding: 12px 20px; border-top: 1px solid #0f3460;
display: flex; gap: 8px; flex-shrink: 0; }
.input-area input { flex: 1; background: #1a1a2e; border: 1px solid #333; border-radius: 8px;
padding: 10px 14px; color: #e0e0e0; font-size: 14px; outline: none; }
.input-area input:focus { border-color: #e94560; }
.input-area button { background: #e94560; color: white; border: none; border-radius: 8px;
padding: 10px 20px; cursor: pointer; font-weight: bold; font-size: 14px; }
.input-area button:disabled { opacity: .5; cursor: not-allowed; }
.input-area button:hover:not(:disabled) { background: #d63851; }
.clear-btn { background: none; border: 1px solid #333; color: #888; padding: 4px 10px;
border-radius: 6px; cursor: pointer; font-size: 12px; }
.clear-btn:hover { border-color: #e94560; color: #e94560; }
</style>
</head>
<body>
<div class="header">
<h1>OpenDAN</h1>
<div style="display:flex;align-items:center;gap:12px">
<span class="status"><span class="dot online" id="statusDot"></span><span id="statusText">Ready</span></span>
<button class="clear-btn" onclick="clearChat()">Clear</button>
</div>
</div>
<div class="chat" id="chat">
<div class="think-bar" id="thinkBar" onmouseenter="showThinkPopup()" onmouseleave="hideThinkPopup()">
<div class="tb-spinner"></div>
<span class="tb-label">Thinking</span>
<span class="tb-detail" id="thinkDetail"></span>
<span class="tb-hint">hover to see output</span>
</div>
</div>
<div class="think-popup" id="thinkPopup" onmouseenter="showThinkPopup()" onmouseleave="hideThinkPopup()">
<div class="tp-label">Raw output</div>
<div id="thinkPopupContent"></div>
</div>
<div class="toolbar">
<button class="tbtn" onclick="injectTool('Run: ')"><span class="ti"></span> Shell</button>
<button class="tbtn" onclick="injectTool('Run python: ')"><span class="ti">🐍</span> Code</button>
<button class="tbtn" onclick="injectTool('Search: ')"><span class="ti">🔍</span> Search</button>
<button class="tbtn" onclick="injectTool('Read: ')"><span class="ti">📄</span> Read</button>
<button class="tbtn" onclick="injectTool('Write to ')"><span class="ti">✏️</span> Write</button>
<button class="tbtn" onclick="injectTool('List: ')"><span class="ti">📁</span> List</button>
<button class="tbtn" onclick="injectTool('What time is it?')"><span class="ti">🕐</span> Now</button>
</div>
<div class="input-area">
<input type="text" id="input" placeholder="Ask me anything..." autofocus
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}">
<button id="sendBtn" onclick="send()">Send</button>
</div>
<script>
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const thinkBar = document.getElementById('thinkBar');
const thinkDetail = document.getElementById('thinkDetail');
const thinkPopup = document.getElementById('thinkPopup');
const thinkPopupContent = document.getElementById('thinkPopupContent');
let threadId = 'default';
let loading = false;
let abortController = null;
let _lastUserText = '';
let _currentMsgEl = null;
let _thinkContent = '';
let _thinkTool = '';
let _popupVisible = false;
function setStatus(state) {
statusDot.className = 'dot ' + state;
if (state === 'online') statusText.textContent = 'Ready';
else if (state === 'busy') statusText.textContent = 'Thinking...';
else if (state === 'waiting') statusText.textContent = 'Awaiting approval';
else statusText.textContent = 'Offline';
}
function scrollBottom() { chat.scrollTop = chat.scrollHeight; }
function createAssistant(contentHtml, isStreaming) {
const div = document.createElement('div');
div.className = 'msg assistant';
div.dataset.streaming = isStreaming ? '1' : '0';
div.innerHTML = `<div class="msg-content">${contentHtml}</div>
<div class="msg-footer">
<button class="action-btn cancel-btn" onclick="cancelMsg(this)" ${isStreaming ? '' : 'style="display:none"'}>✕</button>
<button class="action-btn retry-btn" onclick="retryMsg(this)" ${isStreaming ? 'style="display:none"' : ''}>↻</button>
</div>`;
chat.appendChild(div);
scrollBottom();
return div;
}
function finishStreaming(el) {
if (!el) return;
el.dataset.streaming = '0';
const c = el.querySelector('.cancel-btn'), r = el.querySelector('.retry-btn');
if (c) c.style.display = 'none';
if (r) r.style.display = '';
}
function cancelMsg(btn) {
if (abortController) { abortController.abort(); abortController = null; }
const el = btn.closest('.msg.assistant');
if (el) el.remove();
hideThink();
_currentMsgEl = null;
setStatus('online');
loading = false;
input.disabled = false;
sendBtn.disabled = false;
}
function retryMsg(btn) {
const el = btn.closest('.msg.assistant');
const userEl = el.previousElementSibling;
if (!_lastUserText) return;
if (userEl && userEl.classList.contains('msg.user')) userEl.remove();
el.remove();
input.value = _lastUserText;
send();
}
/* ── Thinking indicator ── */
function showThink() {
thinkBar.classList.add('visible');
_thinkContent = '';
_thinkTool = '';
thinkDetail.textContent = '';
}
function hideThink() {
thinkBar.classList.remove('visible');
thinkPopup.classList.remove('visible');
_popupVisible = false;
_thinkContent = '';
_thinkTool = '';
}
function updateThink(text, toolName) {
if (toolName) _thinkTool = toolName;
if (text) _thinkContent += text;
const detail = _thinkTool ? _thinkTool + '...' : (_thinkContent.slice(-60) || 'generating...');
thinkDetail.textContent = detail;
if (_popupVisible) {
thinkPopupContent.textContent = _thinkContent || '(waiting for output...)';
}
}
function setThinkTool(name) {
_thinkTool = name;
thinkDetail.textContent = name + '...';
}
function showThinkPopup() {
_popupVisible = true;
thinkPopup.classList.add('visible');
thinkPopupContent.textContent = _thinkContent || '(waiting for output...)';
}
function hideThinkPopup() {
_popupVisible = false;
thinkPopup.classList.remove('visible');
}
function addMsg(role, html) {
const div = document.createElement('div');
div.className = 'msg ' + role;
div.innerHTML = html;
chat.appendChild(div);
scrollBottom();
return div;
}
/* ── Proposal cards ── */
function addProposalCards(proposalId, toolCalls) {
const container = document.createElement('div');
container.id = 'proposal-' + proposalId;
container.style.cssText = 'display:flex;flex-direction:column;gap:8px;max-width:100%';
const decisions = {};
for (const tc of toolCalls) {
const card = document.createElement('div');
card.className = 'proposal-card';
card.dataset.tcId = tc.tool_call_id;
const argsStr = JSON.stringify(tc.args, null, 2);
card.innerHTML = `
<div class="pc-header">
<span class="pc-icon">⚡</span>
<span class="pc-name">${escaped(tc.name)}</span>
</div>
<div class="pc-args">${escaped(argsStr)}</div>
<div class="pc-actions">
<button class="btn-approve" onclick="setDecision('${proposalId}','${tc.tool_call_id}','approve')">✅ Approve</button>
<button class="btn-reject active" onclick="setDecision('${proposalId}','${tc.tool_call_id}','reject')">❌ Reject</button>
</div>`;
container.appendChild(card);
decisions[tc.tool_call_id] = false;
}
// Run bar
const runBar = document.createElement('div');
runBar.className = 'run-bar';
runBar.id = 'runbar-' + proposalId;
const approvedCount = 0;
runBar.innerHTML = `
<span class="rb-text" id="rb-text-${proposalId}">Select which tools to run</span>
<button class="rb-btn" id="rb-btn-${proposalId}" disabled onclick="executeProposal('${proposalId}')">▶ Run Selected</button>`;
container.appendChild(runBar);
chat.appendChild(container);
scrollBottom();
window._pd = window._pd || {};
window._pd[proposalId] = decisions;
}
function setDecision(proposalId, tcId, action) {
const pd = window._pd && window._pd[proposalId];
if (!pd) return;
pd[tcId] = action === 'approve';
const card = document.querySelector(`#proposal-${proposalId} .proposal-card[data-tc-id="${tcId}"]`);
if (card) {
const app = card.querySelector('.btn-approve');
const rej = card.querySelector('.btn-reject');
if (action === 'approve') { app.classList.add('active'); rej.classList.remove('active'); }
else { rej.classList.add('active'); app.classList.remove('active'); }
}
// Update run button
const vals = Object.values(pd);
const anyApproved = vals.some(v => v === true);
const allDone = vals.length > 0 && vals.every(v => v !== undefined);
const btn = document.getElementById('rb-btn-' + proposalId);
const txt = document.getElementById('rb-text-' + proposalId);
if (btn) {
const approved = vals.filter(v => v === true).length;
const total = vals.length;
if (approved === 0) {
btn.disabled = true;
txt.textContent = 'Select which tools to run';
} else {
btn.disabled = false;
txt.textContent = `${approved} of ${total} tool${total > 1 ? 's' : ''} approved`;
}
}
}
/* ── Tool execution display (non-proposal, instant mode) ── */
function addToolGroup(name, args) {
const div = document.createElement('div');
div.className = 'tool-group';
div.dataset.toolName = name;
const argsStr = JSON.stringify(args).slice(0, 300);
div.innerHTML = `
<div class="tool-header">
<span class="tool-icon">⚡</span>
<span class="tool-call">${escaped(name)}</span>
<span class="tool-args">${escaped(argsStr)}</span>
</div>
<div class="tool-status"><span class="spinner"></span> Running...</div>`;
chat.appendChild(div);
scrollBottom();
return div;
}
function updateToolGroup(el, result) {
const statusDiv = el.querySelector('.tool-status');
if (result.status === 'success') {
const text = result.stdout || result.content || (result.items ? result.items.length + ' items' : '') || 'Done';
statusDiv.innerHTML = '✅ Done';
if (text) {
const pre = document.createElement('div');
pre.className = 'tool-result-text';
pre.textContent = (typeof text === 'string' ? text : String(text)).slice(0, 2000);
el.appendChild(pre);
}
} else if (result.status === 'skipped') {
statusDiv.innerHTML = '⏭️ Skipped';
const pre = document.createElement('div');
pre.className = 'tool-result-text skipped';
pre.textContent = result.reason || 'Skipped';
el.appendChild(pre);
} else {
statusDiv.innerHTML = '❌ Error';
const pre = document.createElement('div');
pre.className = 'tool-result-text error';
pre.textContent = result.error || 'Tool failed';
el.appendChild(pre);
}
scrollBottom();
}
/* ── Suggestion chips ── */
function addSuggestions(suggestions) {
const sDiv = document.createElement('div');
sDiv.className = 'suggestions';
sDiv.id = 'suggestions-' + Date.now();
for (const s of suggestions) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = s;
chip.onclick = () => { input.value = s; send(); };
sDiv.appendChild(chip);
}
chat.appendChild(sDiv);
scrollBottom();
}
/* ── Send flow ── */
async function send() {
if (loading) return;
const text = input.value.trim();
if (!text) return;
_lastUserText = text;
input.value = '';
addMsg('user', escaped(text));
loading = true;
sendBtn.disabled = true;
input.disabled = true;
setStatus('busy');
showThink();
try {
// Phase 1: Propose
const proposeResp = await fetch('/v1/chat/propose', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: text, thread_id: threadId})
});
const propose = await proposeResp.json();
if (propose.tool_calls && propose.tool_calls.length > 0) {
// Show proposal cards
hideThink();
addProposalCards(propose.proposal_id, propose.tool_calls);
if (propose.content) {
createAssistant(marked.parse(propose.content), false);
}
setStatus('waiting');
loading = false;
sendBtn.disabled = false;
input.disabled = false;
input.focus();
return;
}
// No tools needed — display response directly
if (propose.response) {
hideThink();
createAssistant(marked.parse(propose.response), false);
finishLoading();
return;
}
} catch(e) {
hideThink();
createAssistant('Error: ' + e.message, false);
finishLoading();
}
}
async function executeProposal(proposalId) {
const pd = window._pd && window._pd[proposalId];
if (!pd) return;
const decisions = Object.entries(pd).map(([tcId, approved]) => ({
tool_call_id: tcId, approved
}));
if (decisions.length === 0) return;
// Remove proposal UI
const container = document.getElementById('proposal-' + proposalId);
if (container) container.remove();
window._pd && delete window._pd[proposalId];
loading = true;
sendBtn.disabled = true;
input.disabled = true;
abortController = new AbortController();
setStatus('busy');
const msgDiv = createAssistant('<span class="spinner" style="display:inline-block;width:14px;height:14px;border:2px solid #555;border-top-color:#4ecca3;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle"></span>', true);
try {
const resp = await fetch('/v1/chat/stream', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({proposal_id: proposalId, decisions, thread_id: threadId}),
signal: abortController.signal
});
_currentMsgEl = msgDiv;
await handleStream(resp, msgDiv);
} catch(e) {
if (e.name !== 'AbortError') msgDiv.textContent = 'Error: ' + e.message;
}
msgDiv.id = '';
_currentMsgEl = null;
finishStreaming(msgDiv);
finishLoading();
}
async function streamResponse(originalText) {
loading = true;
abortController = new AbortController();
const msgDiv = createAssistant('<span class="spinner"></span>', true);
msgDiv.id = 'streaming-msg';
try {
const resp = await fetch('/v1/chat/stream', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: originalText, thread_id: threadId}),
signal: abortController.signal
});
_currentMsgEl = msgDiv;
await handleStream(resp, msgDiv);
} catch(e) {
if (e.name !== 'AbortError') msgDiv.textContent = 'Error: ' + e.message;
}
msgDiv.id = '';
_currentMsgEl = null;
finishStreaming(msgDiv);
finishLoading();
}
async function handleStream(resp, msgDiv) {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let content = '';
const toolEls = [];
showThink();
while (true) {
const {done, value} = await reader.read();
if (done) break;
buf += decoder.decode(value, {stream: true});
const lines = buf.split('\n');
buf = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'token') {
const delta = data.data?.choices?.[0]?.delta;
if (delta?.content) {
content += delta.content;
updateThink(delta.content);
if (msgDiv.id === 'streaming-msg') {
try { msgDiv.innerHTML = marked.parse(content, {breaks: true}); }
catch(e) { msgDiv.textContent = content; }
}
scrollBottom();
}
} else if (data.type === 'tool_start') {
const el = addToolGroup(data.name, data.args || {});
if (data.tool_call_id) el.dataset.toolCallId = data.tool_call_id;
toolEls.push(el);
setThinkTool(data.name);
} else if (data.type === 'tool_result') {
const el = toolEls.find(e => {
if (data.tool_call_id) return e.dataset.toolCallId === data.tool_call_id;
return e.dataset.toolName === data.name && !e.dataset.done;
});
if (el) {
el.dataset.done = '1';
updateToolGroup(el, data.result);
}
} else if (data.type === 'suggestions') {
addSuggestions(data.suggestions);
} else if (data.type === 'error') {
const contentDiv = msgDiv.querySelector('.msg-content');
if (contentDiv) contentDiv.textContent = 'Error: ' + data.detail;
else msgDiv.textContent = 'Error: ' + data.detail;
}
} catch(e) {}
}
}
hideThink();
_currentMsgEl = null;
finishStreaming(msgDiv);
}
function finishLoading() {
loading = false;
sendBtn.disabled = false;
input.disabled = false;
input.focus();
setStatus('online');
}
async function clearChat() {
await fetch(`/v1/conversation/${threadId}`, {method: 'DELETE'});
chat.innerHTML = '';
createAssistant('Conversation cleared. How can I help you?', false);
}
function injectTool(template) {
input.value = template;
input.focus();
// Place cursor at end for templates that end with a space (user fills in the rest)
// For "What time is it?" the full prompt is ready to send
if (template.endsWith(' ')) {
const len = input.value.length;
input.setSelectionRange(len, len);
}
}
function escaped(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
</body>
</html>