Compare commits

...

2 Commits

Author SHA1 Message Date
Thomas Hollenstein
4760dbb3a3 Ai Implementation 2026-05-28 01:37:08 +02:00
26b97a1cb4 Merge pull request 'dev-leart' (#1) from dev-leart into master
Reviewed-on: #1
2026-05-26 22:08:47 +02:00
11 changed files with 681 additions and 141 deletions

Binary file not shown.

View File

@ -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

View File

@ -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()

View File

@ -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
View 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

View File

@ -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'&amp;', '&', text)
text = re.sub(r'&lt;', '<', text)
text = re.sub(r'&gt;', '>', text)
text = re.sub(r'&quot;', '"', text)
text = re.sub(r'&#x27;', "'", text)
text = re.sub(r'&nbsp;', ' ', 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)

View File

@ -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
View 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)

View File

@ -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

View File

@ -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()

View File

@ -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("---")