Add custom web server and startup script
This commit is contained in:
@@ -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
@@ -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)
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user