diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..0272f5e --- /dev/null +++ b/start.sh @@ -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 "$@" diff --git a/web/server.py b/web/server.py new file mode 100644 index 0000000..c6ee890 --- /dev/null +++ b/web/server.py @@ -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) diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..71845cd --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,640 @@ + + +
+ + +