From 4760dbb3a3726c197420189fcb26aefb756691f9 Mon Sep 17 00:00:00 2001 From: Thomas Hollenstein Date: Thu, 28 May 2026 01:37:08 +0200 Subject: [PATCH] Ai Implementation --- requirements.txt | Bin 4494 -> 1259 bytes src/backend/__init__.py | 1 + src/backend/chat_manager.py | 177 ++++++++++++++++++++++++-- src/backend/execution_engine.py | 84 +++++++------ src/backend/llm_client.py | 37 ++++++ src/backend/search_manager.py | 182 ++++++++++++++++++++++++--- src/backend/system_prompter.py | 5 +- src/backend/tools.py | 105 ++++++++++++++++ src/main.py | 214 +++++++++++++++++++++++--------- src/ui/chat.py | 13 +- src/ui/navigation.py | 4 - 11 files changed, 681 insertions(+), 141 deletions(-) create mode 100644 src/backend/llm_client.py create mode 100644 src/backend/tools.py diff --git a/requirements.txt b/requirements.txt index 497c1bc3de85a47f80d42ad724d7dd0b46d680f7..a8c6023398f805d5c53bbc8602862be2a733d0ba 100644 GIT binary patch literal 1259 zcmYjR%WmT^47}@KEcg+p?ZJmVEEZT`ffoIMK#`ePjcjQpx@r3LJCsv&Z>B^Ihcl#& zXXDZ^>~*7SWtN)@!?4p$CsAe&7h3W#Ak>XA%n3oeB_GMG>`trCVU4@;YNuOu^lrHj zARGMz5Nqs29D>Kg2Nda)rG#e=I5m)Z-6Cl+DHB&qNVDg zh@NY-i{Q6g-b}mE*sHf3mJFGsGe{_8zK^OL$wDt{*deXR)=I>(ztqGL4nV$Pdvdt64dj0U+IXoNQyidsLy zkLbJ)SM<3x6}{mTOdFjvzk{2NiYd&I4Smh-YhH*`F zpm&1r>Gbu@_R@_7S4R&J`YV_W!=ezKwA-R6Oy}FV1Ws(8#6(4?#V^Iw=`~UIJ zoZ*`*lH9L0i>uKvZNyp`U>!qdsV;LVsT0x?bVGu$$hz|V-EIGDwqBK2RnFvcmMzZ literal 4494 zcmZ{nOK%%j42AEyK!1t?OR^JZkwv=+TEIZjpy)Et%eHL2B8{ymKfdkxc!s(&f(t_) zTjV7V$wOYwzyD0isx)O%mStZ)>F0fUSBCmrm7mK``hHVRl5InF9kOvpdeGTM(&w^} z<{`8!$BHeWv2SIg3;R=@o0J!6EX%!~E9q_Z+?DH&NBoY$+EjW|X)nrm=}f}zqD2mB zG_TK^o6tDP;$G;sI!A<8(jiJmIF-+(q(tBhVCQ2;zL%9*c;xgbP7~QIzJHX23~U2C z8Xu&Ajd9@M8U9D|>^$5^i~nrx@f=T627z~9*7^>7KhEgLE>V7HPa&NNJ)Un{ zSy}`Wu}{KI#?I;Y?dh%jfc`~VJ5?GTW3Tv6vcEd}Cws%lg6lh%WY&7Yl5^Ncqlw zIrAi&?ipv;c}uoDMgKAhX3Fp4%sc8}&ZmF+ystOVDJnmcf7n2OF&C@{Qg;sAa^{Gc zzC8)iR@!^TR3oCQ&iu2i9QDEHs=Vq%?R2ae(@HE|OOAz{g68I3*nW<=^hJN)%K} z!41OfoTyvKf6gTk88aH5O5Bm~zE&RWp7CgHqVM`QV3pFZHS5f2PvD*WuJp52UYyl_ zpH?=xz93`LxGFovw~dT7nhiM@VY_d-sdi;aZ(*>&=F`BHK12DKN%}u9r)ZD8q^_@wNGIw@oJKfs_B!3F)to4&st2Ox| z8PP0a-$*Qdq}g?Y;5(aGmn}p_UOBT_vLWR(I;XdK;6^IzibuU}8mY7UCmg zW41EiJ!8qOeUNoN!?xyd-S zw#{4b3`{RJ!){$_tvv zt str: + if self.active_file_content and self.active_file_path: + return self.system_prompter.add_context(Path(self.active_file_path), self.active_file_content) + return "" + + def _build_system_prompt(self) -> str: + context = self._build_context() + active_path = str(self.active_file_path) if self.active_file_path else None + return f"""You are a code assistant. You help users understand, modify, run, and research code. + +### Current Active File: +{context if context else "No file currently active."} + +### Active File Path: +{active_path if active_path else "No file active."} + +### RESPONSE FORMAT — always reply with exactly one JSON object: + +To use a tool: +{{ + "thought": "why I need this tool", + "tool": "tool_name", + "arguments": {{}} +}} + +To give a final answer (NO tool needed): +{{ + "thought": "my reasoning", + "answer": "my response to the user" +}} + +### WHEN TO USE TOOLS vs ANSWER DIRECTLY: +- Questions about what code does, explanations, analysis → ANSWER DIRECTLY, no tool needed +- "run the file", "execute", "test it" → use execute_file +- "add a comment", "fix this", "change X" → use read_file then edit_file +- "search for X", "look up X", "what is X" (external info) → use search +- "run this snippet" → use execute_code + +### Available Tools: +{build_tool_description()} + +### Tool usage notes: +- edit_file REPLACES the whole file — always read_file first +- execute_file path: "{active_path}" +- After edit_file succeeds, return an "answer" confirming what you did +- Never use execute_code just to print an explanation — answer directly instead +""" + + def generate_response(self, prompt: str, execution_results: dict = None) -> str: + system_prompt = self._build_system_prompt() + user_message = f"User request: {prompt}" + + if self.active_file_path and self.active_file_content: + user_message += f"\n--- ACTIVE FILE ---\nPath: {self.active_file_path}" + elif self.active_file_path: + user_message += f"\n[File loaded: {self.active_file_path} — but content unavailable]" + + if execution_results and execution_results.get("has_errors"): + user_message += "\n[Execution Feedback — errors found]:\n" + for log in execution_results.get("logs", []): + user_message += f" - [{log['level']}] {log['message']}\n" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, + ] + + max_iterations = 8 + for iteration in range(max_iterations): + try: + raw_response = chat_json(self.client, messages, model=MODEL, temperature=0.2) + response_data = json.loads(raw_response) + + # ---- Final answer (no tool) ---- + tool_name = response_data.get("tool") or response_data.get("command") + if not tool_name or tool_name in ("thought", "finish", "answer", "none", ""): + return ( + response_data.get("answer") + or response_data.get("thought") + or raw_response + ) + + arguments = response_data.get("arguments", {}) + + # Resolve path placeholders + if "path" in arguments and arguments["path"] in ("current_file", "current file", ""): + arguments["path"] = str(self.active_file_path) if self.active_file_path else "" + + # Unknown tool + if tool_name not in TOOL_REGISTRY: + messages.append({"role": "assistant", "content": raw_response}) + messages.append({ + "role": "user", + "content": ( + f"ERROR: '{tool_name}' is not a valid tool. " + f"Available tools: {list(TOOL_REGISTRY.keys())}. " + "Use one of those, or respond with an \"answer\" if no tool is needed." + ) + }) + continue + + # ---- Execute tool ---- + result = TOOL_REGISTRY[tool_name](**arguments) + + # Notify UI on successful file write + if tool_name == "edit_file" and not str(result).startswith("ERROR"): + written_path = arguments.get("path", "") + new_content = arguments.get("content", "") + if self.on_file_written and written_path and new_content: + self.on_file_written(written_path, new_content) + + messages.append({"role": "assistant", "content": raw_response}) + messages.append({ + "role": "user", + "content": ( + f"Tool '{tool_name}' result:\n{result}\n\n" + "Now give your final answer to the user using the " + "{\"answer\": \"...\"} format." + ) + }) + + if str(result).startswith("ERROR"): + messages[-1]["content"] += "\nThe tool returned an error — revise and try again, or explain the issue." + + except json.JSONDecodeError: + messages.append({"role": "assistant", "content": raw_response}) + messages.append({ + "role": "user", + "content": "Your response was not valid JSON. Reply with a single JSON object only." + }) + + # Hard fallback — plain chat without JSON constraint + return chat(self.client, messages, model=MODEL, temperature=0.7) + + def get_history(self) -> list: + return self.conversation_history + + def clear_history(self): + self.conversation_history.clear() diff --git a/src/backend/execution_engine.py b/src/backend/execution_engine.py index 9469d30..c821e20 100644 --- a/src/backend/execution_engine.py +++ b/src/backend/execution_engine.py @@ -1,14 +1,15 @@ import ast import subprocess import sys +import os from pathlib import Path +import tempfile from backend.debug_logger import DebugLogger # ---------------- BLOCKED PATTERNS ---------------- -# These AST node patterns are refused before execution BLOCKED_IMPORTS = { - "winreg", "ctypes", "msvcrt", # Windows internals + "winreg", "ctypes", "msvcrt", } BLOCKED_CALLS = { @@ -22,18 +23,11 @@ BLOCKED_CALLS = { BLOCKED_BUILTINS = {"__import__", "eval", "exec", "compile"} -# Paths that are never allowed as cwd or in file arguments BLOCKED_PATH_PREFIXES = [ - "C:\\Windows", - "C:\\System32", - "/etc", - "/bin", - "/sbin", - "/usr/bin", - "/usr/sbin", - "/boot", - "/sys", - "/proc", + "C:\\Windows", "C:\\System32", + "/etc", "/bin", "/sbin", + "/usr/bin", "/usr/sbin", + "/boot", "/sys", "/proc", ] @@ -47,20 +41,14 @@ class ExecutionEngine: # ---------------- SAFETY CHECK ---------------- def _check_safe(self, filepath: str) -> None: - """ - Raises SafetyError if the file contains dangerous patterns. - Only runs on .py files (other types are blocked outright). - """ path = Path(filepath) source = path.read_text(encoding="utf-8") try: tree = ast.parse(source, filename=str(path)) except SyntaxError: - return # syntax errors handled separately + return for node in ast.walk(tree): - - # Block dangerous imports if isinstance(node, (ast.Import, ast.ImportFrom)): names = ( [a.name for a in node.names] @@ -72,29 +60,21 @@ class ExecutionEngine: if root in BLOCKED_IMPORTS: raise SafetyError(f"Blocked import: '{name}'") - # Block dangerous attribute calls like os.remove(...) if isinstance(node, ast.Call): func = node.func if isinstance(func, ast.Attribute): if isinstance(func.value, ast.Name): pair = (func.value.id, func.attr) if pair in BLOCKED_CALLS: - raise SafetyError( - f"Blocked call: '{func.value.id}.{func.attr}()'" - ) - - # Block dangerous builtins: eval(), exec(), __import__() + raise SafetyError(f"Blocked call: '{func.value.id}.{func.attr}()'") if isinstance(func, ast.Name) and func.id in BLOCKED_BUILTINS: raise SafetyError(f"Blocked builtin: '{func.id}()'") - # Block absolute paths pointing at system directories in string literals for node in ast.walk(tree): if isinstance(node, ast.Constant) and isinstance(node.value, str): for prefix in BLOCKED_PATH_PREFIXES: if node.value.lower().startswith(prefix.lower()): - raise SafetyError( - f"Blocked system path in code: '{node.value}'" - ) + raise SafetyError(f"Blocked system path in code: '{node.value}'") # ---------------- SYNTAX CHECK ---------------- def check_syntax(self, filepath: str) -> bool: @@ -113,31 +93,27 @@ class ExecutionEngine: self.logger.log_exception(e) return False - # ---------------- RUN ---------------- + # ---------------- RUN FILE ---------------- def run_file(self, filepath: str) -> dict: self.logger.clear() path = Path(filepath) - # --- file exists --- if not path.exists(): self.logger.log_stderr(f"File not found: {filepath}") return self._result("", f"File not found: {filepath}", -1) ext = path.suffix.lower() - # --- only allow known safe types --- if ext not in (".py", ".js", ".sh"): msg = f"Unsupported file type: {ext}" self.logger.log_stderr(msg) return self._result("", msg, -1) - # --- block shell scripts on Windows (too risky) --- if ext == ".sh" and sys.platform == "win32": msg = "Shell scripts are not supported on Windows." self.logger.log_stderr(msg) return self._result("", msg, -1) - # --- Python: syntax + safety check --- if ext == ".py": if not self.check_syntax(filepath): return self._result("", self.logger.format(), -1) @@ -148,14 +124,11 @@ class ExecutionEngine: self.logger.log_stderr(msg) return self._result("", msg, -1) cmd = [sys.executable, str(path)] - elif ext == ".js": cmd = ["node", str(path)] - elif ext == ".sh": cmd = ["bash", str(path)] - # --- execute --- self.logger.log(f"Running: {path.name}") try: result = subprocess.run( @@ -182,6 +155,39 @@ class ExecutionEngine: self.logger.log_exception(e) return self._result("", str(e), -1) + # ---------------- RUN CODE STRING ---------------- + def run_code(self, code: str) -> str: + """Execute a Python code string and return a human-readable result string.""" + tmp_path = None + try: + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as f: + f.write(code) + tmp_path = f.name + + result = subprocess.run( + [sys.executable, tmp_path], + capture_output=True, + text=True, + timeout=30, + ) + + parts = [] + if result.stdout.strip(): + parts.append(f"Output:\n{result.stdout.strip()}") + if result.stderr.strip(): + parts.append(f"Errors:\n{result.stderr.strip()}") + if result.returncode != 0: + parts.append(f"Exit code: {result.returncode}") + return "\n".join(parts) if parts else "Code executed successfully with no output." + + except subprocess.TimeoutExpired: + return "ERROR: Execution timed out after 30 seconds." + except Exception as e: + return f"ERROR: {str(e)}" + finally: + if tmp_path and os.path.exists(tmp_path): + os.unlink(tmp_path) + # ---------------- HELPERS ---------------- def _result(self, stdout: str, stderr: str, returncode: int) -> dict: return { @@ -195,4 +201,4 @@ class ExecutionEngine: } def get_logger(self) -> DebugLogger: - return self.logger \ No newline at end of file + return self.logger diff --git a/src/backend/llm_client.py b/src/backend/llm_client.py new file mode 100644 index 0000000..55ee6b4 --- /dev/null +++ b/src/backend/llm_client.py @@ -0,0 +1,37 @@ +# backend/llm_client.py +from openai import OpenAI + +HOST = "silicon.fhgr.ch" +PORT = 7080 +API_KEY = "EMPTY" +MODEL = "qwen3.5-35b-a3b" + +def get_client() -> OpenAI: + """Gibt einen OpenAI-kompatiblen Client zurück, der auf den vLLM-Server zeigt.""" + base_url = f"http://{HOST}:{PORT}/v1" + return OpenAI(base_url=base_url, api_key=API_KEY) + +def chat(client: OpenAI, messages: list[dict], model: str = MODEL, + temperature: float = 0.2, max_tokens: int = 2048) -> str: + """Sendet Chat-Nachrichten an das LLM und gibt die Antworttext zurück.""" + response = client.chat.completions.create( + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + extra_body={"chat_template_kwargs": {"enable_thinking": False}}, + ) + return response.choices[0].message.content + +def chat_json(client: OpenAI, messages: list[dict], model: str = MODEL, + temperature: float = 0.2, max_tokens: int = 2048) -> str: + """Wie chat(), aber erzwingt syntaktisch gültiges JSON für Tool-Calls.""" + response = client.chat.completions.create( + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + response_format={"type": "json_object"}, + extra_body={"chat_template_kwargs": {"enable_thinking": False}}, + ) + return response.choices[0].message.content \ No newline at end of file diff --git a/src/backend/search_manager.py b/src/backend/search_manager.py index c8f1411..27eddf7 100644 --- a/src/backend/search_manager.py +++ b/src/backend/search_manager.py @@ -1,30 +1,172 @@ +import urllib.parse +import json +import re + +try: + import requests + HAS_REQUESTS = True +except ImportError: + import urllib.request + HAS_REQUESTS = False + + +# --------------------------------------------------------------------------- +# SearchManager +# Uses two backends in order: +# 1. DuckDuckGo HTML (scrape) — broad web results, no API key needed +# 2. Wikipedia API — reliable fallback, great for tech topics +# --------------------------------------------------------------------------- + +_DDG_URL = "https://html.duckduckgo.com/html/" +_WIKI_URL = "https://en.wikipedia.org/w/api.php" + +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ), + "Accept-Language": "en-US,en;q=0.9", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", +} + + +def _get(url, *, params=None, data=None, timeout=10) -> str: + """Single HTTP helper that works with or without the `requests` library.""" + if HAS_REQUESTS: + if data: + r = requests.post(url, headers=_HEADERS, data=data, timeout=timeout) + else: + r = requests.get(url, headers=_HEADERS, params=params, timeout=timeout) + r.raise_for_status() + return r.text + else: + if params: + url = url + "?" + urllib.parse.urlencode(params) + if data: + encoded = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(url, data=encoded, headers=_HEADERS, method="POST") + else: + req = urllib.request.Request(url, headers=_HEADERS) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + + +# --------------------------------------------------------------------------- +# Backend 1 — DuckDuckGo HTML scrape +# --------------------------------------------------------------------------- + +def _ddg_search(query: str, max_results: int = 5) -> list[dict]: + """POST to DuckDuckGo HTML endpoint and parse result anchors.""" + try: + html = _get(_DDG_URL, data={"q": query, "b": "", "kl": "en-us"}) + except Exception as e: + return [{"title": "DuckDuckGo unavailable", "url": "", "snippet": str(e)}] + + results = [] + + # Each result block looks like: + # Title + # Snippet + title_pattern = re.compile(r'class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)', re.S) + snippet_pattern = re.compile(r'class="result__snippet"[^>]*>(.*?)', re.S) + + titles = title_pattern.findall(html) + snippets = snippet_pattern.findall(html) + + def clean(text: str) -> str: + text = re.sub(r'<[^>]+>', '', text) # strip tags + text = re.sub(r'&', '&', text) + text = re.sub(r'<', '<', text) + text = re.sub(r'>', '>', text) + text = re.sub(r'"', '"', text) + text = re.sub(r''', "'", text) + text = re.sub(r' ', ' ', text) + return text.strip() + + for i, (url, title) in enumerate(titles[:max_results]): + # DDG sometimes wraps URLs in redirects like //duckduckgo.com/l/?uddg=... + if url.startswith("//duckduckgo.com/l/?") or url.startswith("/l/?"): + m = re.search(r'uddg=([^&]+)', url) + if m: + url = urllib.parse.unquote(m.group(1)) + snippet = clean(snippets[i]) if i < len(snippets) else "" + results.append({"title": clean(title), "url": url, "snippet": snippet}) + + return results + + +# --------------------------------------------------------------------------- +# Backend 2 — Wikipedia API (reliable, no key, great for tech/science) +# --------------------------------------------------------------------------- + +def _wiki_search(query: str, max_results: int = 3) -> list[dict]: + try: + data = _get(_WIKI_URL, params={ + "action": "query", + "list": "search", + "srsearch": query, + "format": "json", + "srlimit": max_results, + "srprop": "snippet|titlesnippet", + }) + obj = json.loads(data) + results = [] + for r in obj.get("query", {}).get("search", []): + title = r.get("title", "") + snippet = re.sub(r'<[^>]+>', '', r.get("snippet", "")) + url = f"https://en.wikipedia.org/wiki/{urllib.parse.quote(title.replace(' ', '_'))}" + results.append({"title": title, "url": url, "snippet": snippet}) + return results + except Exception as e: + return [{"title": "Wikipedia unavailable", "url": "", "snippet": str(e)}] + + +# --------------------------------------------------------------------------- +# Public class +# --------------------------------------------------------------------------- + class SearchManager: """ - Verwaltet Suchanfragen und integriert externe APIs [1] + Web search using DuckDuckGo (primary) + Wikipedia (fallback/supplement). + No API key required. """ + def __init__(self, api_key=None): - self.api_key = api_key - self.search_history = [] - - def perform_search(self, query): + self.api_key = api_key # kept for interface compatibility + self.search_history: list[str] = [] + + def perform_search(self, query: str, max_results: int = 5) -> list[dict]: """ - Führt eine Internetsuche aus [1] + Return a list of dicts with keys: title, url, snippet. + Tries DuckDuckGo first; if it yields nothing, falls back to Wikipedia. """ - # Platzhalter-Implementierung für Startphase - return [] - - def parse_results(self, raw_results): - """ - Extrahiert und formatiert relevante Ergebnisse [1] - """ - # Rückgabe einer formatierten Liste als Platzhalter + self.search_history.append(query) + + results = _ddg_search(query, max_results) + + # Filter out error-only results and supplement with Wikipedia if thin + real = [r for r in results if r["url"]] + if len(real) < 2: + real = _wiki_search(query, max_results) + + return real if real else [{"title": "No results", "url": "", "snippet": f"No results found for: {query}"}] + + def parse_results(self, raw_results: list) -> list: if isinstance(raw_results, list): return raw_results return [] - - def search_context(self, query, context_type="web"): - """ - Fügt Suchergebnisse dem Kontext hinzu - """ + + def search_context(self, query: str, context_type: str = "web") -> str: + """Formatted string ready to inject into a prompt.""" results = self.perform_search(query) - return f"Search results for '{query}': {results}" \ No newline at end of file + if not results: + return f"No results found for '{query}'." + lines = [f"Search results for '{query}':"] + for i, r in enumerate(results, 1): + lines.append(f"\n{i}. {r['title']}") + if r["url"]: + lines.append(f" URL: {r['url']}") + if r["snippet"]: + lines.append(f" {r['snippet']}") + return "\n".join(lines) diff --git a/src/backend/system_prompter.py b/src/backend/system_prompter.py index 3187608..2c34184 100644 --- a/src/backend/system_prompter.py +++ b/src/backend/system_prompter.py @@ -2,16 +2,14 @@ class SystemPrompter: """ Generiert System-Prompts mit Dateikontext für die KI [1] """ - def __init__(self): + def init(self): self.default_prompt = "You are a code assistant helping to debug Python code." - def generate_prompt(self, user_message, file_context): """ Baut ein vollständiges Prompt mit Dateiinhalten oder Kontextinformationen auf [1] """ # Platzhalter-Implementierung für Startphase return f"User: {user_message}\nContext: {file_context}" - def add_context(self, file_name, code_snippet=None): """ Fügt Dateikontext hinzu (Dateiname, Änderungen oder markierte Code-Abschnitte) [1] @@ -20,7 +18,6 @@ class SystemPrompter: if code_snippet: context += f"\nCode:\n{code_snippet}" return context - def summarize_context(self, context): """ Fasst Kontext zusammen, um den Prompt nicht zu überladen [1] diff --git a/src/backend/tools.py b/src/backend/tools.py new file mode 100644 index 0000000..a9d21ac --- /dev/null +++ b/src/backend/tools.py @@ -0,0 +1,105 @@ +import json +import inspect +from pathlib import Path +from backend.file_manager import FileManager +from backend.search_manager import SearchManager +from backend.execution_engine import ExecutionEngine + +TOOL_REGISTRY = {} + +# Optional callback set by ChatManager so the UI can react to tool usage +# Signature: on_tool_used(tool_name: str, summary: str) +on_tool_used = None + +def tool(func): + """Decorator to register a function as an available tool""" + TOOL_REGISTRY[func.__name__] = func + return func + + +@tool +def read_file(path: str) -> str: + """Read the content of a file. Use this to examine code you need to modify.""" + try: + fm = FileManager() + content = fm.read_file(path) + return f"Content of {path}:\n{content}" + except Exception as e: + return f"ERROR: Could not read {path}: {str(e)}" + + +@tool +def search(query: str) -> str: + """Search the internet for documentation, answers, or examples. Use whenever the user asks something that needs current or external information.""" + try: + sm = SearchManager() + result = sm.search_context(query) + # Notify UI that a search was performed + if on_tool_used: + on_tool_used("search", f"🔍 Searched: \"{query}\"") + return result + except Exception as e: + return f"ERROR: Search failed: {str(e)}" + + +@tool +def execute_file(path: str) -> str: + """Execute a Python (.py), JavaScript (.js), or shell (.sh) file and return its output, errors, and exit code.""" + try: + ee = ExecutionEngine() + result = ee.run_file(path) + parts = [] + if result.get("stdout", "").strip(): + parts.append(f"Output:\n{result['stdout'].strip()}") + if result.get("stderr", "").strip(): + parts.append(f"Errors:\n{result['stderr'].strip()}") + status = "✓ Success" if result.get("success") else f"✗ Failed (exit code {result.get('returncode', -1)})" + parts.append(f"Status: {status}") + output = "\n".join(parts) if parts else "Executed successfully with no output." + if on_tool_used: + on_tool_used("execute_file", f"▶ Executed: {path}") + return output + except Exception as e: + return f"ERROR: Could not execute {path}: {str(e)}" + + +@tool +def execute_code(code: str) -> str: + """Execute a Python code snippet and return its output or errors. Use to test logic or verify suggestions.""" + try: + ee = ExecutionEngine() + result = ee.run_code(code) + if on_tool_used: + on_tool_used("execute_code", "▶ Executed code snippet") + return result + except Exception as e: + return f"ERROR: {str(e)}" + + +@tool +def edit_file(path: str, content: str) -> str: + """Save content to a file, replacing its entire contents. Always read_file first so you preserve existing code.""" + try: + fm = FileManager() + success = fm.save_file(path, content) + if success: + return f"Successfully saved to {path}" + return f"ERROR: Could not save to {path}" + except Exception as e: + return f"ERROR: {str(e)}" + + +def build_tool_description() -> str: + """Build a description of all available tools from their signatures.""" + lines = [] + for name, func in TOOL_REGISTRY.items(): + sig = inspect.signature(func) + params = [] + for pname, param in sig.parameters.items(): + if param.default is inspect.Parameter.empty: + params.append(f"{pname}") + else: + params.append(f"{pname}={param.default}") + doc = func.__doc__ or "No description" + lines.append(f"{name}: {doc}. Arguments: {params}") + return "\n".join(lines) diff --git a/src/main.py b/src/main.py index 95d0179..d3eb59f 100644 --- a/src/main.py +++ b/src/main.py @@ -2,9 +2,9 @@ import sys import os from pathlib import Path import streamlit as st -# ---------------- PATH FIX ---------------- + sys.path.append(os.path.dirname(os.path.abspath(__file__))) -# ---------------- BACKEND ---------------- + from backend.file_manager import FileManager from ui.navigation import FileNavigation from ui.chat import ChatInterface @@ -13,13 +13,7 @@ from ui.output import OutputDisplay from backend.chat_manager import ChatManager from backend.execution_engine import ExecutionEngine -# ---------------- PAGE CONFIG ---------------- -st.set_page_config( - page_title="AI Code Editor", - page_icon="💻", - layout="wide" -) - +st.set_page_config(page_title="AI Code Editor", page_icon="💻", layout="wide") st.markdown(""" """, unsafe_allow_html=True) -# ---------------- MAIN ---------------- def main(): - file_manager = FileManager() - navigation = FileNavigation() - chat = ChatInterface() - chat_manager = ChatManager() + # --- Initialize Backend Components --- + file_manager = FileManager() + navigation = FileNavigation() + chat_manager = ChatManager(file_manager=file_manager) execution_engine = ExecutionEngine() - output_display = OutputDisplay() + output_display = OutputDisplay() + + # --- UI Components --- + chat = ChatInterface(chat_manager=chat_manager) + editor = CodeEditor() # ---------------- STATE ---------------- - if "root_folder" not in st.session_state: - st.session_state.root_folder = None - if "selected_file" not in st.session_state: - st.session_state.selected_file = None - if "active_file" not in st.session_state: - st.session_state.active_file = None - if "editor_content" not in st.session_state: - st.session_state.editor_content = "" - if "last_applied_content" not in st.session_state: - st.session_state.last_applied_content = "" - if "run_result" not in st.session_state: - st.session_state.run_result = None - if "chat_open" not in st.session_state: - st.session_state.chat_open = False + if "root_folder" not in st.session_state: st.session_state.root_folder = None + if "selected_file" not in st.session_state: st.session_state.selected_file = None + if "active_file" not in st.session_state: st.session_state.active_file = None + if "editor_content" not in st.session_state: st.session_state.editor_content = "" + if "last_applied_content" not in st.session_state: st.session_state.last_applied_content = "" + if "run_result" not in st.session_state: st.session_state.run_result = None + if "chat_open" not in st.session_state: st.session_state.chat_open = False + if "chat_messages" not in st.session_state: st.session_state.chat_messages = [] + if "ai_wrote_file" not in st.session_state: st.session_state.ai_wrote_file = False + if "tool_events" not in st.session_state: st.session_state.tool_events = [] + + # --- Restore active file context on every rerender --- + if st.session_state.get("active_file") and st.session_state.get("editor_content"): + chat_manager.set_active_file( + Path(st.session_state.active_file), + st.session_state.editor_content + ) + else: + chat_manager.set_active_file(None, "") + + # --- Wire up the AI file-write callback --- + def on_file_written(path: str, new_content: str): + if str(path) == str(st.session_state.get("active_file")): + st.session_state.editor_content = new_content + st.session_state.last_applied_content = new_content + st.session_state.ai_wrote_file = True + + chat_manager.on_file_written = on_file_written + + # --- Wire up tool-use notification --- + def on_tool_used(tool_name: str, summary: str): + st.session_state.tool_events.append(summary) + + chat_manager.on_tool_used = on_tool_used # ---------------- SIDEBAR ---------------- + st.sidebar.markdown("---") + selected_file = navigation.render_sidebar() if selected_file: st.session_state.selected_file = selected_file - st.sidebar.markdown("---") - current_file = st.session_state.selected_file # ---------------- FILE SWITCH ---------------- if current_file != st.session_state.active_file: st.session_state.active_file = current_file - st.session_state.run_result = None + st.session_state.run_result = None if current_file: content = file_manager.read_file(current_file) - st.session_state.editor_content = content + st.session_state.editor_content = content st.session_state.last_applied_content = content + chat_manager.set_active_file(Path(st.session_state.active_file), content) + st.success(f"File loaded: {Path(current_file)}") else: st.session_state.editor_content = "" + chat_manager.set_active_file(None, "") - chat.inject_panel(st.session_state.chat_open) + if st.session_state.chat_open: + chat.inject_panel(True) - # ---------------- MAIN UI ---------------- - if st.session_state.active_file: - st.subheader(f"✏️ {Path(st.session_state.active_file).name}") + # ---------------- MAIN UI (split: editor left, chat right) ---------------- + editor_col, chat_col = st.columns(2, gap="medium") - editor = CodeEditor() - new_value, run_clicked = editor.display_code( - st.session_state.editor_content, - filename=st.session_state.active_file + with editor_col: + st.subheader( + f"✏️ {Path(st.session_state.active_file).name}" + if st.session_state.active_file else "📂 Select a file from the explorer" ) - # ---------------- APPLY DETECTION ---------------- - if new_value is not None: - if new_value != st.session_state.last_applied_content: - st.session_state.editor_content = new_value + if st.session_state.active_file: + new_value, run_clicked = editor.display_code( + st.session_state.editor_content, + filename=st.session_state.active_file + ) + if new_value is not None and new_value != st.session_state.last_applied_content: + st.session_state.editor_content = new_value st.session_state.last_applied_content = new_value file_manager.save_file(st.session_state.active_file, new_value) + chat_manager.set_active_file(Path(st.session_state.active_file), new_value) st.success("Saved ✔") - # ---------------- RUN ---------------- - if run_clicked: - with st.spinner("Running…"): - st.session_state.run_result = execution_engine.run_file( - st.session_state.active_file - ) + if run_clicked: + with st.spinner("Running…"): + st.session_state.run_result = execution_engine.run_file(st.session_state.active_file) + if st.session_state.run_result is not None: + st.markdown("---") + st.caption("Output") + output_display.render_output(st.session_state.run_result) + else: + st.info("Select a file from the explorer") - if st.session_state.run_result is not None: - st.markdown("---") - st.caption("Output") - output_display.render_output(st.session_state.run_result) + # ---------------- CHAT COLUMN (right) ---------------- + with chat_col: + if not st.session_state.chat_open: + st.subheader("💬 AI Assistant") - else: - st.info("Select a file from the explorer") + # Scrollable messages area — Streamlit's container(height=) gives it + # a fixed viewport that scrolls, so the input below is always visible. + messages_container = st.container(height=600, border=False) + with messages_container: + messages = st.session_state.chat_messages + if not messages: + st.caption("Ask anything about your code.") + for i, message in enumerate(messages): + with st.chat_message(message["role"]): + if ( + message["role"] == "assistant" + and i == len(messages) - 1 + and message.get("tool_events") + ): + for event in message["tool_events"]: + st.markdown(f'
{event}
', unsafe_allow_html=True) + st.markdown(message["content"]) + + # Input lives OUTSIDE the scrollable container — always at the bottom + if prompt := st.chat_input("Ask about your code..."): + st.session_state.tool_events = [] + st.session_state.chat_messages.append({"role": "user", "content": prompt}) + + with messages_container: + with st.chat_message("user"): + st.markdown(prompt) + + with st.chat_message("assistant"): + with st.spinner("AI is thinking..."): + response = chat_manager.generate_response(prompt, st.session_state.run_result) + + tool_events = list(st.session_state.tool_events) + for event in tool_events: + st.markdown(f'
{event}
', unsafe_allow_html=True) + + st.markdown(response) + + st.session_state.chat_messages.append({ + "role": "assistant", + "content": response, + "tool_events": tool_events, + }) + + if st.session_state.ai_wrote_file: + st.session_state.ai_wrote_file = False + st.rerun() + + if st.sidebar.button("🗑️ Clear Chat"): + st.session_state.chat_messages.clear() + st.session_state.tool_events = [] + chat_manager.clear_history() + st.rerun() if __name__ == "__main__": - main() \ No newline at end of file + main() +# End of main.py \ No newline at end of file diff --git a/src/ui/chat.py b/src/ui/chat.py index c88dcc8..b9b123d 100644 --- a/src/ui/chat.py +++ b/src/ui/chat.py @@ -6,13 +6,15 @@ class ChatInterface: Chat Interface for AI interaction, including the right-side slide-out panel. """ - def __init__(self): + def __init__(self, chat_manager=None): + # chat_manager accepted for compatibility with main.py + self.chat_manager = chat_manager self.chat_messages = [] # ---------------- PANEL INJECTION ---------------- def inject_panel(self, is_open: bool): """Injects or removes the fixed right-side AI chat panel via st.iframe.""" - st.iframe(f""" + st.components.v1.html(f"""