Compare commits

...

21 Commits

Author SHA1 Message Date
6e669a2234 Update README.md 2026-05-29 17:37:51 +02:00
5b32b4e6d0 Update README.md 2026-05-29 17:32:44 +02:00
Livio Meuli
a70efea25f Fix tests 2026-05-29 14:33:13 +02:00
Livio Meuli
c702675758 Remove env Variablen konfiguration aus dem Git 2026-05-29 14:14:29 +02:00
Livio Meuli
adfb780f7c Remove Latex 2026-05-29 13:47:28 +02:00
Livio Meuli
8209136bed Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project 2026-05-28 11:49:41 +02:00
Livio Meuli
3dcea4ece2 ReadMeUpdate 2026-05-28 11:49:37 +02:00
Irina Rueegg
db371b906c Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project 2026-05-28 11:46:55 +02:00
Irina Rueegg
e5e06da35b delete unused sidebarfunctions 2026-05-28 11:46:10 +02:00
Livio Meuli
9633def4de remove TItel 2026-05-28 11:44:49 +02:00
Irina Rueegg
3e8bab87cb Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project 2026-05-28 11:30:10 +02:00
Irina Rueegg
46b9c925dd Result expander in Agent log 2026-05-28 11:28:54 +02:00
6a7e90ab40 Merge pull request 'func_improvments' (#24) from func_improvments into main
Reviewed-on: meulilivio/AISE1_Project#24
2026-05-28 11:27:09 +02:00
Livio Meuli
3216a9a5f6 fix Merge Confict 2026-05-28 11:25:52 +02:00
Livio Meuli
ba40f6fc82 Merge branch 'main' of https://gitea.fhgr.ch/meulilivio/AISE1_Project into func_improvments 2026-05-28 11:21:19 +02:00
Livio Meuli
a8403eee92 small changes 2026-05-28 11:19:44 +02:00
Irina Rueegg
ea05437154 remove rejection feedback after onclick 2026-05-28 10:10:25 +02:00
Irina Rueegg
da236ce14e toggle file context 2026-05-28 09:46:20 +02:00
33969a7b59 Merge pull request 'Remove some stuff' (#22) from func_improvments into main
Reviewed-on: meulilivio/AISE1_Project#22
2026-05-28 09:18:52 +02:00
Livio Meuli
8327c54bdb Remove some stuff 2026-05-28 09:17:08 +02:00
9836c2981a Merge pull request 'finetuning' (#21) from finetuning into main
Reviewed-on: meulilivio/AISE1_Project#21
2026-05-28 09:14:42 +02:00
14 changed files with 241 additions and 261 deletions

View File

@ -3,11 +3,9 @@
# NOTE: Never commit .env with real secrets to version control! # NOTE: Never commit .env with real secrets to version control!
# Silicon Server Configuration # Silicon Server Configuration
HOST=silicon.fhgr.ch HOST=
PORT=7080 PORT=
API_KEY=EMPTY API_KEY=EMPTY
MODEL=qwen3.5-35b-a3b MODEL=
# Optional: Add more configuration variables as needed
# DEBUG=False
# LOG_LEVEL=INFO

158
README.md
View File

@ -4,6 +4,15 @@ KI-unterstützter Lightweight Code Editor auf Basis von Streamlit (AISE501 Sprin
--- ---
## Projektinformationen
| | |
|---|---|
| **Modul** | AI in Software Engineering 1 (AISE501) |
| **Autoren** | Irina Rüegg & Livio Meuli |
| **Semester** | Spring 2026 |
---
## Inhaltsverzeichnis ## Inhaltsverzeichnis
1. [Projektstruktur](#projektstruktur) 1. [Projektstruktur](#projektstruktur)
@ -71,52 +80,124 @@ AISE_AIAgent/
## Schnellstart ## Schnellstart
### 1. Repository klonen > Die folgenden Schritte funktionieren auf **Windows** und **macOS** — abweichende Befehle sind jeweils mit dem Betriebssystem gekennzeichnet.
---
### Schritt 1 — Voraussetzungen prüfen
**Python 3.10 oder neuer** muss installiert sein.
```bash ```bash
git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project.git # Windows (PowerShell)
cd AISE1_Project python --version
# macOS (Terminal)
python3 --version
``` ```
### 2. Virtuelle Umgebung aktivieren Falls Python nicht installiert ist:
- **Windows:** [python.org/downloads](https://www.python.org/downloads/) herunterladen und installieren. Bei der Installation „Add Python to PATH" aktivieren.
- **macOS:** [python.org/downloads](https://www.python.org/downloads/) herunterladen und installieren, **oder** via Homebrew: `brew install python3`
**Git** muss ebenfalls installiert sein:
```bash
git --version
```
Falls nicht vorhanden: [git-scm.com](https://git-scm.com/downloads) (Windows) bzw. `brew install git` (macOS).
---
### Schritt 2 — Repository klonen
```bash ```bash
# Windows git clone https://gitea.fhgr.ch/meulilivio/AISE1_Project_Irina_Livio.git
.\.venv\Scripts\Activate.ps1 cd AISE1_Project_Irina_Livio
```
# macOS / Linux ---
### Schritt 3 — Virtuelle Umgebung erstellen
Eine virtuelle Umgebung isoliert die Projekt-Abhängigkeiten vom restlichen System. Sie muss einmalig erstellt werden.
```bash
# Windows (PowerShell)
python -m venv .venv
# macOS (Terminal)
python3 -m venv .venv
```
---
### Schritt 4 — Virtuelle Umgebung aktivieren
Die Umgebung muss **jedes Mal neu aktiviert** werden, wenn ein neues Terminal geöffnet wird.
```bash
# Windows (PowerShell)
.\.venv\Scripts\Activate.ps1
```
> Falls PowerShell die Ausführung blockiert, einmalig folgenden Befehl ausführen und danach erneut versuchen:
> ```powershell
> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
> ```
```bash
# macOS (Terminal)
source .venv/bin/activate source .venv/bin/activate
``` ```
### 3. Abhängigkeiten installieren Nach erfolgreicher Aktivierung erscheint `(.venv)` am Anfang der Eingabezeile.
---
### Schritt 5 — Abhängigkeiten installieren
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 4. Umgebungsvariablen konfigurieren Dieser Schritt lädt alle benötigten Pakete herunter (~2–5 Minuten je nach Internetverbindung). Er muss nur einmal ausgeführt werden.
---
### Schritt 6 — Umgebungsvariablen konfigurieren
```bash ```bash
# Windows (PowerShell)
copy .env.example .env
# macOS (Terminal)
cp .env.example .env cp .env.example .env
# .env öffnen und HOST, PORT, API_KEY, MODEL eintragen
``` ```
### 5. App starten Danach die Datei `.env` in einem Texteditor öffnen und die Werte eintragen welche per Mail mitgeteilt wurden (HOST, PORT, API_KEY, MODEL).
---
### Schritt 7 — App starten
```bash ```bash
# Windows & macOS
streamlit run frontend/app.py streamlit run frontend/app.py
``` ```
### 6. Tests ausführen Streamlit öffnet die App automatisch im Standard-Browser unter `http://localhost:8501`.
Falls der Browser nicht automatisch aufgeht, die URL manuell eingeben.
Zum **Beenden** der App im Terminal `Ctrl + C` drücken.
---
### Schritt 8 — Tests ausführen (optional)
```bash ```bash
pytest tests/ -v pytest tests/ -v
``` ```
**Optionale Abhängigkeit:** Die LaTeX-Unterstützung (`.tex`-Ausführung) erfordert `pdflatex`
im System-`PATH`. Ohne `pdflatex` funktioniert der Editor weiterhin; beim Ausführen einer
`.tex`-Datei erscheint ein `FileNotFoundError` im stderr-Panel.
--- ---
## Frontend ## Frontend
@ -171,10 +252,12 @@ vorausgefüllten Debug-Nachricht.
Zwei sich gegenseitig ausschliessende Ansichten, umgeschaltet via `st.toggle("Agent Mode")`: Zwei sich gegenseitig ausschliessende Ansichten, umgeschaltet via `st.toggle("Agent Mode")`:
**Normaler Chat** (`render_normal_chat()`): **Normaler Chat** (`render_normal_chat()`):
- Websuch-Panel oben — öffnet sich automatisch, wenn Suchergebnisse aktiv sind. - Kompakte 4-spaltige Toolbar direkt über dem Chat-Input:
`[● Agent Mode]` `[🔍 Search]` `[🗑️ Clear]` `[⚙️ Settings]`
— Web-Suche und Einstellungen jeweils als `st.popover`, Clear öffnet einen Bestätigungs-Dialog.
- System-Prompt wird vor jeder ausgehenden Nachricht neu generiert (`_set_system_prompt()`). - System-Prompt wird vor jeder ausgehenden Nachricht neu generiert (`_set_system_prompt()`).
- Unterstützt Slash-Befehle `/search <Abfrage>` und `/search clear`. - Unterstützt Slash-Befehle `/search <Abfrage>` und `/search clear`.
- Einstellungs-Expander: Dateikontext-Toggle, Modell-Auswahl, Max-Token-Slider, - Settings-Popover: Dateikontext-Toggle, Modell-Auswahl, Max-Token-Slider,
benutzerdefinierter System-Prompt. benutzerdefinierter System-Prompt.
- «Mit KI debuggen»-Nachrichten vom Editor werden über `pending_debug_message` im - «Mit KI debuggen»-Nachrichten vom Editor werden über `pending_debug_message` im
Session-State weitergeleitet. Session-State weitergeleitet.
@ -228,7 +311,9 @@ Generiert kontextbewusste System-Prompts. Signatur:
SystemPrompter.generate_prompt( SystemPrompter.generate_prompt(
user_message="", user_message="",
file_context=None, # {"name": str, "content": str} file_context=None, # {"name": str, "content": str}
search_context=None, # reserviert, noch nicht verdrahtet search_context=None, # list[{"title", "url", "snippet"}] — in system_prompter.py implementiert,
# aber in chat.py nicht verwendet: dort wird Search-Kontext direkt
# als <search_context>-Block vor die Nachricht eingefügt
task_type="default", # "debug" | "explain" | "optimize" | "default" task_type="default", # "debug" | "explain" | "optimize" | "default"
) )
``` ```
@ -241,8 +326,7 @@ Funktion oder Klasse zurückzugeben, nach der der Benutzer fragt, anstatt die ge
### `execution_engine.py` ### `execution_engine.py`
Führt Dateien in einem Subprocess mit `capture_output=True`, `text=True` und einem Führt Dateien in einem Subprocess mit `capture_output=True`, `text=True` und einem
`RUN_TIMEOUT` von 30 Sekunden aus. Aktuell unterstützt: `RUN_TIMEOUT` von 30 Sekunden aus. Aktuell unterstützt:
- `.py` — via `py`-Launcher (Windows) / `sys.executable` - `.py` — via `sys.executable` (plattformübergreifend; zeigt auf den aktuell aktiven Python-Interpreter)
- `.tex` — via `pdflatex -interaction=nonstopmode` (erfordert pdflatex im PATH)
Rückgabe: `{"stdout": str, "stderr": str, "rc": int}`. Rückgabe: `{"stdout": str, "stderr": str, "rc": int}`.
@ -297,7 +381,7 @@ Wichtige Methoden:
| `start_task(task)` | Setzt den gesamten Zustand zurück, befüllt History mit System + Aufgabe | | `start_task(task)` | Setzt den gesamten Zustand zurück, befüllt History mit System + Aufgabe |
| `propose_next_action()` | Ruft das LLM auf, parst JSON, speichert als `pending_action` | | `propose_next_action()` | Ruft das LLM auf, parst JSON, speichert als `pending_action` |
| `approve()` | Führt das ausstehende Tool via `dispatch_tool()` aus, loggt Ergebnis | | `approve()` | Führt das ausstehende Tool via `dispatch_tool()` aus, loggt Ergebnis |
| `reject(feedback)` | Injiziert Feedback + Replan-Tag; schlägt neue Aktion vor | | `reject(feedback)` | Injiziert Feedback + Replan-Tag in die History; `propose_next_action()` wird danach separat aufgerufen |
| `follow_up(question)` | Fügt nach «done» eine Folgefrage ein, setzt Schleife fort | | `follow_up(question)` | Fügt nach «done» eine Folgefrage ein, setzt Schleife fort |
Hilfsfunktionen (Modul-Ebene): Hilfsfunktionen (Modul-Ebene):
@ -308,7 +392,7 @@ Hilfsfunktionen (Modul-Ebene):
| `trim_messages(msgs)` | Entfernt alte Turns, wenn History `MAX_HISTORY_CHARS` (80 000 Zeichen) überschreitet; System-Nachricht + Original-Aufgabe bleiben immer erhalten | | `trim_messages(msgs)` | Entfernt alte Turns, wenn History `MAX_HISTORY_CHARS` (80 000 Zeichen) überschreitet; System-Nachricht + Original-Aufgabe bleiben immer erhalten |
| `_strip_code_fences(text)` | Entfernt ` ```json `- / ` ``` `-Wrapper aus LLM-Antworten | | `_strip_code_fences(text)` | Entfernt ` ```json `- / ` ``` `-Wrapper aus LLM-Antworten |
| `dispatch_tool(name, arguments)` | Leitet weiter an `MCPToolAdapter.call_tool()` | | `dispatch_tool(name, arguments)` | Leitet weiter an `MCPToolAdapter.call_tool()` |
| `get_tool_descriptions()` | Erstellt eine menschenlesbare Tool-Liste für den System-Prompt | | `build_all_tool_description()` | Erstellt eine menschenlesbare Tool-Liste für den System-Prompt |
### `mcp_server_adapter.py` ### `mcp_server_adapter.py`
Liest `mcp_server_config.json`, startet jeden Server als stdio-Subprocess (immer mit Liest `mcp_server_config.json`, startet jeden Server als stdio-Subprocess (immer mit
@ -335,12 +419,11 @@ Alle drei Server sind FastMCP-Applikationen, die über stdio kommunizieren.
- Beide Tools nutzen SSRF-Schutz (gleiche URL-Validierung wie `search_manager.py`) - Beide Tools nutzen SSRF-Schutz (gleiche URL-Validierung wie `search_manager.py`)
**`mcp_server_code_execution.py`** — Sandbox-Python-Analyse: **`mcp_server_code_execution.py`** — Sandbox-Python-Analyse:
- `check_code_safety(code)` — statische Analyse; blockiert gefährliche Imports/Builtins
- `analyse_structure(code)` — AST-basierte Strukturzusammenfassung - `analyse_structure(code)` — AST-basierte Strukturzusammenfassung
- `lint_code(code)` — pyflakes-Analyse - `lint_code(code)` — pyflakes-Analyse
- `python_code_validation(code)` — Sicherheits- + Syntaxprüfung ohne Ausführung - `python_code_validation(code)` — Sicherheits- + Syntaxprüfung ohne Ausführung
- `run_python_sandboxed(code)` — Ausführung in einem Subprocess mit `PYTHONIOENCODING=utf-8`, - `run_python_sandboxed(code)` — Ausführung in einem Subprocess mit `PYTHONIOENCODING=utf-8`,
10 s Timeout, Output begrenzt auf `MAX_OUTPUT_LENGTH` 15 s Timeout, Output begrenzt auf `MAX_OUTPUT_LENGTH`
--- ---
@ -527,3 +610,28 @@ beteiligt ist.
Die App funktioniert mit jedem OpenAI-kompatiblen API-Endpunkt (vLLM, Ollama mit Die App funktioniert mit jedem OpenAI-kompatiblen API-Endpunkt (vLLM, Ollama mit
OpenAI-Shim, OpenAI selbst usw.). OpenAI-Shim, OpenAI selbst usw.).
---
## Einsatz von KI-Werkzeugen
Während der Entwicklung wurden KI-Assistenten (Claude, GitHub Copilot) als
Werkzeuge eingesetzt — vergleichbar mit der Nutzung von Dokumentation, Stack Overflow
oder einer IDE mit Autocomplete.
Konkret bedeutet das:
- **Eigenständige Konzeption und Architektur**: Die Gesamtarchitektur (Schichtentrennung
Frontend / Manager / Agent), die Designentscheidungen und die Aufteilung in Komponenten
wurden selbst erarbeitet und geplant.
- **Implementierung mit Unterstützung**: Boilerplate-Code, Docstrings und einzelne
Hilfsfunktionen wurden teils mit KI-Unterstützung geschrieben, verstanden und
anschliessend in das Projekt integriert.
- **MCP-Integration und Chat-Logik**: Für das Model Context Protocol und den
Chat-Assistenten haben wir uns an den Kursbeispielen des Dozenten orientiert und
diese als Ausgangsbasis adaptiert und erweitert.
- **Debugging und Refactoring**: KI wurde als Gesprächspartner genutzt, um Fehler zu
analysieren und Lösungsansätze zu diskutieren — die Entscheidungen wurden jedoch
eigenständig getroffen und umgesetzt.
Der gesamte Code wurde von uns gelesen, verstanden und bewusst eingesetzt.
Unkritisch übernommener oder nicht verstandener Code wurde nicht ins Projekt aufgenommen.

View File

@ -167,6 +167,7 @@ Example:
- After validation passes, run it with run_python to verify correctness. - After validation passes, run it with run_python to verify correctness.
- If an error occurs, analyse it and try to fix it (up to 3 retries). - If an error occurs, analyse it and try to fix it (up to 3 retries).
- Stay within the workspace directory. - Stay within the workspace directory.
- Never use emojis, umlauts (ä, ö, ü, Ä, Ö, Ü, ß), or any non-ASCII characters in string literals or print() calls — the execution environment uses cp1252 encoding which cannot handle them.
- When the task is fully complete, call the "done" tool. - When the task is fully complete, call the "done" tool.
- If you receive a <human_message>, acknowledge it and adjust your plan. - If you receive a <human_message>, acknowledge it and adjust your plan.
- If you receive a <replan> tag, revise your plan before choosing the next tool. - If you receive a <replan> tag, revise your plan before choosing the next tool.

View File

@ -6,6 +6,7 @@ processes from blocking the UI indefinitely.
""" """
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from backend.managers.debug_logger import get_logger from backend.managers.debug_logger import get_logger
@ -40,15 +41,7 @@ class ExecutionEngine:
# Build the shell command depending on file type # Build the shell command depending on file type
if suffix == ".py": if suffix == ".py":
cmd = ["py", active_file.name] cmd = [sys.executable, active_file.name]
elif suffix == ".tex":
# pdflatex in non-interactive mode so it never waits for input
cmd = [
"pdflatex",
"-interaction=nonstopmode",
f"-output-directory={current_dir}",
active_file.name,
]
else: else:
return {"stdout": "", "stderr": f"Unsupported file type: {suffix}", "rc": 1} return {"stdout": "", "stderr": f"Unsupported file type: {suffix}", "rc": 1}

View File

@ -9,26 +9,38 @@ logger = get_logger(__name__)
MAX_FILE_CHARS = 4000 MAX_FILE_CHARS = 4000
# Per-task base prompts — selected via the task_type parameter. # Per-task base prompts — selected via the task_type parameter.
# Appended to every prompt — ensures generated code is safe to run on Windows
# where the console encoding is cp1252 and cannot handle emojis or non-ASCII chars.
_CODE_SAFETY_NOTE = (
" When writing or suggesting code, never use emojis, umlauts (ä, ö, ü, Ä, Ö, Ü, ß), "
"or any non-ASCII characters in string literals or print statements, "
"as the execution environment uses cp1252 encoding which cannot handle them."
)
_TASK_PROMPTS: dict[str, str] = { _TASK_PROMPTS: dict[str, str] = {
"debug": ( "debug": (
"You are a debugging expert integrated into a lightweight code editor. " "You are a debugging expert integrated into a lightweight code editor. "
"Focus on identifying and fixing errors. " "Focus on identifying and fixing errors. "
"Be concise and precise. Use markdown and fenced code blocks where appropriate." "Be concise and precise. Use markdown and fenced code blocks where appropriate."
+ _CODE_SAFETY_NOTE
), ),
"explain": ( "explain": (
"You are a code explainer integrated into a lightweight code editor. " "You are a code explainer integrated into a lightweight code editor. "
"Use simple language and examples. " "Use simple language and examples. "
"Be concise and precise. Use markdown and fenced code blocks where appropriate." "Be concise and precise. Use markdown and fenced code blocks where appropriate."
+ _CODE_SAFETY_NOTE
), ),
"optimize": ( "optimize": (
"You are a code optimization expert integrated into a lightweight code editor. " "You are a code optimization expert integrated into a lightweight code editor. "
"Focus on performance and readability. " "Focus on performance and readability. "
"Be concise and precise. Use markdown and fenced code blocks where appropriate." "Be concise and precise. Use markdown and fenced code blocks where appropriate."
+ _CODE_SAFETY_NOTE
), ),
"default": ( "default": (
"You are an expert code assistant integrated into a lightweight code editor. " "You are an expert code assistant integrated into a lightweight code editor. "
"Help the user with code suggestions, debugging, explanations, and improvements. " "Help the user with code suggestions, debugging, explanations, and improvements. "
"Be concise and precise. Use markdown and fenced code blocks where appropriate." "Be concise and precise. Use markdown and fenced code blocks where appropriate."
+ _CODE_SAFETY_NOTE
), ),
} }

View File

@ -38,15 +38,13 @@ def main():
st.markdown( st.markdown(
""" """
<style> <style>
.block-container { padding-top: 1rem; } .block-container { padding-top: 4rem; }
[data-testid="stSidebarContent"] { padding-top: 1rem; } [data-testid="stSidebarContent"] { padding-top: 0rem; }
</style> </style>
""", """,
unsafe_allow_html=True, unsafe_allow_html=True,
) )
st.title("Lightweight code editor")
render_sidebar() render_sidebar()
# Switch between the two main views based on the sidebar radio button # Switch between the two main views based on the sidebar radio button

View File

@ -85,6 +85,12 @@ def _reject_action(feedback: str):
st.session_state.agent_pending_action = next_action st.session_state.agent_pending_action = next_action
st.session_state.agent_status = "waiting_approval" st.session_state.agent_status = "waiting_approval"
def _handle_reject():
feedback = st.session_state.agent_reject_feedback
with st.spinner("Agent is replanning..."):
_reject_action(feedback)
st.session_state.agent_reject_feedback = ""
def _followup_agent(question: str): def _followup_agent(question: str):
"""Continue a finished task by injecting a follow-up question and resuming the loop.""" """Continue a finished task by injecting a follow-up question and resuming the loop."""
@ -188,14 +194,15 @@ def render_agent_mode():
st.caption(f"Thought: {step['thought']}") st.caption(f"Thought: {step['thought']}")
if step.get("arguments"): if step.get("arguments"):
_render_arguments(step["arguments"]) _render_arguments(step["arguments"])
result_text = step.get("result", "") with st.expander("➡️ Result", expanded=False):
# Colour the result based on whether the tool succeeded or failed. result_text = step.get("result", "")
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"): # Colour the result based on whether the tool succeeded or failed.
st.error(result_text) if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
elif result_text.startswith("OK") or result_text.startswith("DONE"): st.error(result_text)
st.success(result_text) elif result_text.startswith("OK") or result_text.startswith("DONE"):
else: st.success(result_text)
st.code(result_text, language=None) else:
st.code(result_text, language=None)
# ── Idle: task input ────────────────────────────────────────────────────── # ── Idle: task input ──────────────────────────────────────────────────────
if agent_status == "idle": if agent_status == "idle":
@ -225,7 +232,10 @@ def render_agent_mode():
if args: if args:
_render_arguments(args) _render_arguments(args)
feedback = st.text_input( if "agent_reject_feedback" not in st.session_state:
st.session_state.agent_reject_feedback = ""
st.text_input(
"Rejection feedback (optional):", "Rejection feedback (optional):",
key="agent_reject_feedback", key="agent_reject_feedback",
placeholder="e.g. Use a different approach...", placeholder="e.g. Use a different approach...",
@ -238,10 +248,11 @@ def render_agent_mode():
_approve_action() _approve_action()
st.rerun() st.rerun()
with col2: with col2:
if st.button("Reject", use_container_width=True): st.button(
with st.spinner("Agent is replanning..."): "Reject",
_reject_action(feedback) use_container_width=True,
st.rerun() on_click=_handle_reject,
)
with col3: with col3:
if st.button("Abort Task", use_container_width=True): if st.button("Abort Task", use_container_width=True):
_reset_agent() _reset_agent()
@ -368,7 +379,7 @@ def _clear_chat_dialog():
# ── Normal Chat ─────────────────────────────────────────────────────────────── # ── Normal Chat ───────────────────────────────────────────────────────────────
def _render_search_panel(): def _render_search_panel():
"""Render the collapsible web search panel above the chat history. """Render the collapsible web search panel above the chat toolbar.
Stores results in session_state.search_results so they are automatically Stores results in session_state.search_results so they are automatically
injected as context into the next message the user sends. injected as context into the next message the user sends.
@ -424,9 +435,6 @@ def render_normal_chat():
logger.info("Chat mode") logger.info("Chat mode")
chat_manager: ChatManager = st.session_state.chat_manager chat_manager: ChatManager = st.session_state.chat_manager
# Search panel always rendered at the top — expands automatically when results are active.
_render_search_panel()
# Apply model/token overrides from the Settings panel before any API call. # Apply model/token overrides from the Settings panel before any API call.
if st.session_state.get("selected_model"): if st.session_state.get("selected_model"):
chat_manager.model = st.session_state.selected_model chat_manager.model = st.session_state.selected_model
@ -456,7 +464,58 @@ def render_normal_chat():
with st.chat_message(message["role"]): with st.chat_message(message["role"]):
st.markdown(message["content"]) st.markdown(message["content"])
# Chat input — Enter to send, no extra button needed. # ── Toolbar — directly above the sticky chat input ────────────────────────
# In Streamlit, st.chat_input is a fixed footer. Elements placed BEFORE it
# in code appear in the scrollable content area right above the input bar.
_render_search_panel()
col_clear, col_agent, col_settings = st.columns([1, 1, 1])
with col_clear:
if st.button("🗑️ Clear Chat", use_container_width=True):
_clear_chat_dialog()
with col_agent:
st.toggle("Agent Mode", key="agent_mode")
with col_settings:
with st.popover("⚙️ Settings", use_container_width=True):
current_file = st.session_state.get("active_file")
if current_file:
st.toggle(
f"Include current file as context: **{Path(current_file).name}**",
key="include_file_context",
value=True,
)
else:
st.toggle(
"Include current file as context",
key="include_file_context",
value=True,
)
st.divider()
default_model = chat_manager.model or ""
model_options = [default_model] if default_model else []
st.selectbox("Model", model_options, key="selected_model")
st.slider(
"Max Response Tokens",
min_value=256,
max_value=8000,
value=chat_manager.max_tokens,
step=256,
key="chat_max_tokens",
)
st.divider()
st.text_area(
"Custom System Prompt (overrides default if set)",
key="custom_system_prompt",
height=120,
placeholder="Leave empty to use the default assistant prompt with optional file context.",
)
# Chat input — sticky footer, always at the very bottom of the viewport.
# Supports /search <query> and /search clear as special commands. # Supports /search <query> and /search clear as special commands.
user_input = st.chat_input("Type a message or /search <query>...") user_input = st.chat_input("Type a message or /search <query>...")
if user_input: if user_input:
@ -535,42 +594,6 @@ def render_normal_chat():
st.session_state.chat_history.append({"role": "assistant", "content": ai_response}) st.session_state.chat_history.append({"role": "assistant", "content": ai_response})
st.rerun() st.rerun()
# 5d — Clear Chat opens a confirmation dialog instead of deleting immediately.
if st.button("🗑️ Clear Chat"):
_clear_chat_dialog()
# Toggle to switch to Agent Mode (render_normal_chat and render_agent_mode are
# mutually exclusive, so the same key here causes no DuplicateWidgetID conflict).
st.toggle("Agent Mode", key="agent_mode")
# 5h — Settings expander: file context toggle, model, token limit, custom prompt.
with st.expander("⚙️ Settings", expanded=False):
st.toggle("Include current file as context", key="include_file_context", value=True)
st.divider()
default_model = chat_manager.model or ""
model_options = [default_model] if default_model else []
for m in ["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307", "gpt-4o", "gpt-4o-mini"]:
if m not in model_options:
model_options.append(m)
st.selectbox("Model", model_options, key="selected_model")
st.slider(
"Max Response Tokens",
min_value=256, max_value=8000,
value=chat_manager.max_tokens,
step=256, key="chat_max_tokens",
)
st.divider()
st.text_area(
"Custom System Prompt (overrides default if set)",
key="custom_system_prompt",
height=120,
placeholder="Leave empty to use the default assistant prompt with optional file context.",
)
# ── Entry point ─────────────────────────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────────────────────────

View File

@ -13,13 +13,12 @@ logger = get_logger(__name__)
# Maps file extensions to Ace editor language modes for syntax highlighting. # Maps file extensions to Ace editor language modes for syntax highlighting.
LANG_MAP = { LANG_MAP = {
".py": "python", ".tex": "latex", ".js": "javascript", ".py": "python", ".js": "javascript",
".html": "html", ".css": "css", ".sh": "bash", ".html": "html", ".css": "css", ".sh": "bash",
".json": "json", ".yaml": "yaml", ".yml": "yaml" ".json": "json", ".yaml": "yaml", ".yml": "yaml"
} }
# ── Modals ──────────────────────────────────────────────────────────────────── # ── Modals ────────────────────────────────────────────────────────────────────
@st.dialog("Rename File") @st.dialog("Rename File")
@ -209,7 +208,7 @@ class FileViewer:
with cols[1]: with cols[1]:
st.download_button( st.download_button(
label="⬇ Download", label="Download",
data=st.session_state.files_content.get(file_path, ""), data=st.session_state.files_content.get(file_path, ""),
file_name=Path(file_path).name, file_name=Path(file_path).name,
mime="text/plain", mime="text/plain",

View File

@ -110,106 +110,6 @@ def _add_folder_dialog(parent_path: str = ""):
if cancel: if cancel:
st.rerun() st.rerun()
@st.dialog("Rename File")
def _rename_file_dialog(relative_file_path: str, file_name: str):
"""
Dialog to rename a file.
Args:
relative_file_path (str): The current relative path (without base path) to the file to rename, including the file name.
file_name (str): The current name of the file, including the extension.
"""
st.write(f"Current name: **{file_name}**")
with st.form("rename_file_form"):
new_name = st.text_input(
"New name:",
value=Path(relative_file_path).stem
)
col1, col2 = st.columns(2)
with col1:
submitted = st.form_submit_button(
"Confirm",
type="primary",
use_container_width=True
)
with col2:
cancel = st.form_submit_button(
"Cancel",
use_container_width=True
)
if submitted:
if not new_name.strip():
st.warning("Please enter a name.")
elif "/" in new_name or "\\" in new_name or "." in new_name:
st.warning("Name must not contain slashes.")
else:
if fm.rename_file(relative_file_path, new_name.strip()):
absolute_file_path = str(Path(fm.base_path / relative_file_path))
ext = Path(absolute_file_path).suffix
new_file_path = str(
Path(absolute_file_path).parent / (Path(new_name.strip()).stem + ext)
)
if absolute_file_path in st.session_state.open_files:
i = st.session_state.open_files.index(absolute_file_path)
st.session_state.open_files[i] = new_file_path
if absolute_file_path in st.session_state.files_content:
st.session_state.files_content[new_file_path] = \
st.session_state.files_content.pop(absolute_file_path)
if st.session_state.active_file == absolute_file_path:
st.session_state.active_file = new_file_path
st.rerun()
else:
st.error("Rename failed. Check that the file still exists.")
st.error(f"Attempted to rename: {relative_file_path} to {new_name.strip()}")
if cancel:
st.rerun()
@st.dialog("Delete File")
def _delete_file_dialog(relative_file_path: str, file_name: str):
"""Confirmation dialog before permanently deleting a file.
After a successful delete the file is also removed from the editor's
open-files list and content cache so it cannot be saved back to disk.
Args:
relative_file_path: Workspace-relative path to the file (used by FileManager).
file_name: Display name shown in the warning message.
"""
st.warning(f"Delete **{file_name}**? This cannot be undone.")
col1, col2 = st.columns(2)
with col1:
if st.button("Delete", type="primary", use_container_width=True):
if fm.delete_file(relative_file_path):
abs_file_path = str(Path(fm.base_path) / relative_file_path)
st.session_state.open_files.remove(abs_file_path)
st.session_state.files_content.pop(abs_file_path, None)
# Fall back to the first remaining open file, or None if all tabs are closed.
if st.session_state.active_file == abs_file_path:
st.session_state.active_file = (
st.session_state.open_files[0]
if st.session_state.open_files else None
)
st.rerun()
else:
st.error("Delete failed. Check that the file still exists.")
with col2:
if st.button("Cancel", use_container_width=True):
st.rerun()
# ── File tree ───────────────────────────────────────────────────────────────── # ── File tree ─────────────────────────────────────────────────────────────────
def build_arborist_tree(tree, parent_path=Path()): def build_arborist_tree(tree, parent_path=Path()):
@ -274,10 +174,10 @@ def render_filetree_arborist(tree):
selected = tree_view( selected = tree_view(
data=data, data=data,
icons={"open": "📂", "closed": "📁"}, icons={"open": "📂", "closed": "📁"},
height=400, height=350,
selection=active_selection, selection=active_selection,
select_internal_nodes=True, # allow clicking folder names, not just files select_internal_nodes=True, # allow clicking folder names, not just files
open_by_default=True open_by_default=True,
) )
return selected return selected
@ -369,17 +269,6 @@ def render_sidebar():
_add_folder_dialog(folder_rel) _add_folder_dialog(folder_rel)
if st.button("Delete Folder", key="btn_delete_folder", use_container_width=True): if st.button("Delete Folder", key="btn_delete_folder", use_container_width=True):
_delete_folder_dialog(folder_rel, folder_name) _delete_folder_dialog(folder_rel, folder_name)
if st.session_state.get("active_file"):
active_file_name = Path(st.session_state.active_file).name
file_rel = str(Path(st.session_state.active_file).relative_to(fm.base_path))
with st.container(border=True):
st.write(f"**File actions:** {active_file_name}")
if st.button("Rename File", key="btn_rename_file", use_container_width=True):
_rename_file_dialog(file_rel, active_file_name)
if st.button("Delete File", key="btn_delete_active_file", use_container_width=True):
_delete_file_dialog(file_rel, active_file_name)
with add_more: with add_more:
# Popover for workspace-root actions (not tied to any selected folder). # Popover for workspace-root actions (not tied to any selected folder).
@ -407,6 +296,5 @@ def render_sidebar():
st.success(f"'{uploaded.name}' uploaded successfully.") st.success(f"'{uploaded.name}' uploaded successfully.")
if __name__ == "__main__": if __name__ == "__main__":
render_sidebar() render_sidebar()

Binary file not shown.

View File

@ -1,5 +1,5 @@
# Core Framework # Core Framework
streamlit>=1.28.0 streamlit==1.57.0
streamlit_arborist>=0.1.0 streamlit_arborist>=0.1.0
# AI/LLM Integration # AI/LLM Integration

View File

@ -1,3 +1,4 @@
import sys
import pytest import pytest
import subprocess import subprocess
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
@ -61,26 +62,6 @@ def test_run_python_file_error(mock_run, engine, tmp_path):
assert "ZeroDivisionError" in result["stderr"] assert "ZeroDivisionError" in result["stderr"]
# ---------------------------------------------------------
# 3. LaTeX-Datei wird kompiliert
# ---------------------------------------------------------
@patch("subprocess.run")
def test_run_tex_file_success(mock_run, engine, tmp_path):
file = tmp_path / "doc.tex"
file.write_text("\\documentclass{article}")
mock_run.return_value = Mock(
stdout="PDF created",
stderr="",
returncode=0
)
result = engine.run_code(file)
assert result["rc"] == 0
assert "PDF created" in result["stdout"]
# --------------------------------------------------------- # ---------------------------------------------------------
# 4. Unsupported File Type # 4. Unsupported File Type
@ -327,34 +308,13 @@ def test_run_unicode_filename(mock_run, engine, tmp_path):
assert result["rc"] == 0 assert result["rc"] == 0
# --------------------------------------------------------- # ---------------------------------------------------------
# 16. .tex nutzt pdflatex # 17. .py nutzt sys.executable als Interpreter
# --------------------------------------------------------- # ---------------------------------------------------------
@patch("subprocess.run") @patch("subprocess.run")
def test_tex_uses_pdflatex(mock_run, engine, tmp_path): def test_python_uses_sys_executable(mock_run, engine, tmp_path):
file = tmp_path / "doc.tex"
file.write_text("x")
mock_run.return_value = Mock(
stdout="",
stderr="",
returncode=0
)
engine.run_code(file)
args, _ = mock_run.call_args
assert args[0][0] == "pdflatex"
# ---------------------------------------------------------
# 17. .py nutzt py Interpreter
# ---------------------------------------------------------
@patch("subprocess.run")
def test_python_uses_py_interpreter(mock_run, engine, tmp_path):
file = tmp_path / "main.py" file = tmp_path / "main.py"
file.write_text("print(1)") file.write_text("print(1)")
@ -368,7 +328,7 @@ def test_python_uses_py_interpreter(mock_run, engine, tmp_path):
args, _ = mock_run.call_args args, _ = mock_run.call_args
assert args[0][0] == "py" assert args[0][0] == sys.executable
# --------------------------------------------------------- # ---------------------------------------------------------