Compare commits
2 Commits
4c5cbfa035
...
4760dbb3a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4760dbb3a3 | ||
| 26b97a1cb4 |
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@ -1,5 +1,6 @@
|
||||
from .file_manager import FileManager
|
||||
from .chat_manager import ChatManager
|
||||
from .tools import TOOL_REGISTRY, build_tool_description
|
||||
from .system_prompter import SystemPrompter
|
||||
from .search_manager import SearchManager
|
||||
from .execution_engine import ExecutionEngine
|
||||
|
||||
@ -1,15 +1,170 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .llm_client import get_client, chat, chat_json, MODEL
|
||||
from .system_prompter import SystemPrompter
|
||||
from .file_manager import FileManager
|
||||
from .tools import TOOL_REGISTRY, build_tool_description
|
||||
from .execution_engine import ExecutionEngine
|
||||
import backend.tools as tools_module
|
||||
|
||||
|
||||
class ChatManager:
|
||||
"""
|
||||
Platzhalter für die Chat-Kommunikation [1]
|
||||
"""
|
||||
def __init__(self):
|
||||
def __init__(self, file_manager=None, execution_engine=None):
|
||||
self.conversation_history = []
|
||||
self.system_prompter = SystemPrompter()
|
||||
self.file_manager = file_manager if file_manager else FileManager()
|
||||
self.execution_engine = execution_engine if execution_engine else ExecutionEngine()
|
||||
self.client = get_client()
|
||||
self.active_file_path = None
|
||||
self.active_file_content = None
|
||||
self.on_file_written = None
|
||||
self.on_tool_used = None
|
||||
tools_module.on_tool_used = self._tool_used_callback
|
||||
|
||||
def generate_response(self, prompt):
|
||||
"""
|
||||
Gibt eine Dummy-Antwort zurück [1]
|
||||
"""
|
||||
return "Antwort (Backend-Logik noch nicht implementiert)"
|
||||
def _tool_used_callback(self, tool_name: str, summary: str):
|
||||
if self.on_tool_used:
|
||||
self.on_tool_used(tool_name, summary)
|
||||
|
||||
def get_history(self):
|
||||
return []
|
||||
def set_active_file(self, file_path: str, content: str = None):
|
||||
self.active_file_path = file_path
|
||||
self.active_file_content = content if content else None
|
||||
|
||||
def _build_context(self) -> 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()
|
||||
|
||||
@ -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
|
||||
return self.logger
|
||||
|
||||
37
src/backend/llm_client.py
Normal file
37
src/backend/llm_client.py
Normal file
@ -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
|
||||
@ -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:
|
||||
# <a class="result__a" href="...">Title</a>
|
||||
# <a class="result__snippet">Snippet</a>
|
||||
title_pattern = re.compile(r'class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>', re.S)
|
||||
snippet_pattern = re.compile(r'class="result__snippet"[^>]*>(.*?)</a>', 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}"
|
||||
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)
|
||||
|
||||
@ -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]
|
||||
|
||||
105
src/backend/tools.py
Normal file
105
src/backend/tools.py
Normal file
@ -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)
|
||||
214
src/main.py
214
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("""
|
||||
<style>
|
||||
.stMainBlockContainer { padding-top: 2rem !important; }
|
||||
@ -28,88 +22,192 @@ st.markdown("""
|
||||
body.chat-open .main .block-container { margin-right: 400px; }
|
||||
iframe[height="1"] { display: none !important; }
|
||||
[data-testid="stSidebar"] { z-index: 99 !important; }
|
||||
/* Tool-use badge styling */
|
||||
.tool-badge {
|
||||
display: inline-block;
|
||||
background: #1e3a5f;
|
||||
color: #7eb8f7;
|
||||
border: 1px solid #2d5a9e;
|
||||
border-radius: 6px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.82em;
|
||||
margin: 2px 0 6px 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
/* Pin chat input to bottom of its column */
|
||||
[data-testid="stChatInput"] {
|
||||
position: sticky !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 10 !important;
|
||||
padding-top: 0.5rem !important;
|
||||
background: var(--background-color, white) !important;
|
||||
}
|
||||
</style>
|
||||
""", 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'<div class="tool-badge">{event}</div>', 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'<div class="tool-badge">{event}</div>', 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()
|
||||
main()
|
||||
# End of main.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"""
|
||||
<script>
|
||||
const win = window.parent;
|
||||
const doc = win.document;
|
||||
@ -90,8 +92,9 @@ class ChatInterface:
|
||||
""", height=1)
|
||||
|
||||
# ---------------- CHAT DISPLAY ----------------
|
||||
def display_chat(self, chat_manager):
|
||||
def display_chat(self, chat_manager=None):
|
||||
"""Renders the Streamlit chat interface (used when not in panel mode)."""
|
||||
manager = chat_manager or self.chat_manager
|
||||
st.title("AI Assistant")
|
||||
|
||||
for message in self.chat_messages:
|
||||
@ -104,7 +107,7 @@ class ChatInterface:
|
||||
st.markdown(prompt)
|
||||
|
||||
with st.chat_message("assistant"):
|
||||
response = chat_manager.generate_response(prompt)
|
||||
response = manager.generate_response(prompt) if manager else "No chat manager connected."
|
||||
st.markdown(response)
|
||||
self.chat_messages.append({"role": "assistant", "content": response})
|
||||
|
||||
@ -113,4 +116,4 @@ class ChatInterface:
|
||||
self.chat_messages.append({"role": role, "content": content})
|
||||
|
||||
def clear_history(self):
|
||||
self.chat_messages.clear()
|
||||
self.chat_messages.clear()
|
||||
|
||||
@ -140,10 +140,6 @@ class FileNavigation:
|
||||
st.session_state.root_folder = picked
|
||||
st.session_state._folder_snapshot = None
|
||||
st.rerun()
|
||||
with btn_right:
|
||||
if st.button("💬 Ask AI", key="toggle_chat", use_container_width=True):
|
||||
st.session_state.chat_open = not st.session_state.get("chat_open", False)
|
||||
st.rerun()
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user