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)