2026-05-22 14:25:22 +02:00

389 lines
16 KiB
Python

"""Chat view — renders both the normal chat interface and the Coding Agent mode."""
import asyncio
from pathlib import Path
import streamlit as st
from backend.managers.chat_manager import ChatManager
from backend.managers.system_prompter import SystemPrompter
# ── Agent Mode helpers ────────────────────────────────────────────────────────
def _run_async(coro):
"""Hilfsfunktion um async Code in sync Streamlit auszuführen"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def _start_agent(task: str):
"""Create a new CodingAgent, feed it the task, and propose the first action.
Stores the agent and its state in session_state so Streamlit can reference
them across reruns without losing progress.
"""
from backend.agent.coding_agent import CodingAgent
agent = CodingAgent()
agent.start_task(task)
action = _run_async(agent.propose_next_action())
st.session_state.coding_agent = agent
st.session_state.agent_pending_action = action
st.session_state.agent_status = "waiting_approval"
st.session_state.agent_log = []
def _approve_action():
"""Execute the pending action, log it, then immediately propose the next step."""
agent = st.session_state.coding_agent
pending = st.session_state.agent_pending_action
result = _run_async(agent.approve())
# Append a record to the log so the user can review every completed step.
st.session_state.agent_log.append({
"thought": pending.get("thought", ""),
"tool": result["tool"],
"arguments": result.get("arguments", {}),
"result": result["result"],
})
if result["is_done"]:
# Agent called the "done" tool — task is fully complete.
st.session_state.agent_status = "done"
st.session_state.agent_pending_action = None
else:
next_action = _run_async(agent.propose_next_action())
st.session_state.agent_pending_action = next_action
st.session_state.agent_status = "waiting_approval"
def _reject_action(feedback: str):
"""Reject the pending action with feedback so the agent replans.
The pending action is discarded; the agent receives the user's feedback and
proposes a different approach on the next call to propose_next_action().
"""
agent = st.session_state.coding_agent
agent.reject(feedback or "Please try a different approach.")
next_action = _run_async(agent.propose_next_action())
st.session_state.agent_pending_action = next_action
st.session_state.agent_status = "waiting_approval"
def _followup_agent(question: str):
"""Continue a finished task by injecting a follow-up question and resuming the loop."""
agent = st.session_state.coding_agent
agent.follow_up(question)
action = _run_async(agent.propose_next_action())
st.session_state.agent_pending_action = action
st.session_state.agent_status = "waiting_approval"
def _reset_agent():
"""Clear all agent state and return to the idle (task input) screen."""
st.session_state.coding_agent = None
st.session_state.agent_status = "idle"
st.session_state.agent_log = []
st.session_state.agent_pending_action = None
# ── Agent Mode UI ─────────────────────────────────────────────────────────────
def render_agent_mode():
"""Render the step-by-step agent UI.
Three distinct screens based on agent_status:
- "idle" → task description input + Start button
- "waiting_approval" → show proposed action, Approve / Reject / Abort
- "done" → success message, follow-up input, New Task button
"""
# The toggle must always render so Streamlit keeps agent_mode=True in session_state.
st.toggle("Agent Mode", key="agent_mode")
agent_status = st.session_state.get("agent_status", "idle")
agent_log = st.session_state.get("agent_log", [])
# ── Agent Log ────────────────────────────────────────────────────────────
# Collapsed by default so it doesn't clutter the UI during active tasks.
if agent_log:
with st.expander(f"Agent Log — {len(agent_log)} step(s) completed", expanded=False):
for i, step in enumerate(agent_log):
with st.chat_message("assistant"):
st.markdown(f"**Step {i + 1} — `{step['tool']}`**")
st.caption(f"Thought: {step['thought']}")
if step.get("arguments"):
st.json(step["arguments"])
result_text = step.get("result", "")
# Colour the result based on whether the tool succeeded or failed.
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
st.error(result_text)
elif result_text.startswith("OK") or result_text.startswith("DONE"):
st.success(result_text)
else:
st.code(result_text, language=None)
# ── Idle: task input ──────────────────────────────────────────────────────
if agent_status == "idle":
task = st.text_area(
"Describe what the agent should do:",
key="agent_task_input",
height=120,
placeholder="e.g. Write a function that sorts a list and saves it to sorted.py",
)
if st.button("Start Agent", type="primary", use_container_width=True):
#loop = asyncio.new_event_loop()
#asyncio.set_event_loop(loop)
if task.strip():
with st.spinner("Agent is thinking..."):
_start_agent(task.strip())
st.rerun()
else:
st.warning("Please describe a task first.")
# ── Waiting: show proposed action + Approve / Reject ─────────────────────
elif agent_status == "waiting_approval":
pending = st.session_state.get("agent_pending_action", {})
with st.status("Agent proposes the following step:", expanded=True):
st.markdown(f"**Thought:** {pending.get('thought', '')}")
st.markdown(f"**Tool:** `{pending.get('tool', '')}`")
args = pending.get("arguments", {})
if args:
# Show file content separately as a code block for readability;
# other arguments are displayed as JSON.
if "content" in args:
display_args = {k: v for k, v in args.items() if k != "content"}
if display_args:
st.json(display_args)
st.code(args["content"], language="python")
else:
st.json(args)
feedback = st.text_input(
"Rejection feedback (optional):",
key="agent_reject_feedback",
placeholder="e.g. Use a different approach...",
)
col1, col2, col3 = st.columns([3, 2, 2])
with col1:
if st.button("Approve", type="primary", use_container_width=True):
with st.spinner("Executing and planning next step..."):
_approve_action()
st.rerun()
with col2:
if st.button("Reject", use_container_width=True):
with st.spinner("Agent is replanning..."):
_reject_action(feedback)
st.rerun()
with col3:
if st.button("Abort Task", use_container_width=True):
_reset_agent()
st.rerun()
# ── Done ─────────────────────────────────────────────────────────────────
elif agent_status == "done":
last_result = agent_log[-1]["result"] if agent_log else ""
st.success(f"Task completed! {last_result}")
st.divider()
followup = st.text_area(
"Follow-up question or correction:",
key="agent_followup_input",
height=80,
placeholder="e.g. The output is wrong — it should sort descending. Can you fix that?",
)
col1, col2 = st.columns(2)
with col1:
if st.button("Ask Follow-up", type="primary", use_container_width=True):
if followup.strip():
with st.spinner("Agent is thinking..."):
_followup_agent(followup.strip())
st.rerun()
else:
st.warning("Please enter a follow-up question first.")
with col2:
if st.button("New Task", use_container_width=True):
_reset_agent()
st.rerun()
# ── Normal Chat helpers ───────────────────────────────────────────────────────
def _build_file_context() -> dict | None:
"""Return file context for the system prompt if a file is open and context is enabled.
Reads from files_content cache first; falls back to FileManager if the file
has not been loaded into the editor yet.
"""
if not st.session_state.get("include_file_context", True):
return None
active_file = st.session_state.get("active_file")
if not active_file:
return None
content = st.session_state.get("files_content", {}).get(active_file, "")
if not content:
try:
from backend.managers.file_manager import FileManager
fm = FileManager()
content = fm.read_file(Path(active_file)) or ""
except Exception:
return None
return {"name": Path(active_file).name, "content": content}
@st.dialog("Clear Chat")
def _clear_chat_dialog():
"""Confirmation dialog before wiping the full conversation history."""
st.warning("All messages will be deleted. This cannot be undone.")
col1, col2 = st.columns(2)
with col1:
if st.button("Clear", type="primary", use_container_width=True):
st.session_state.chat_manager.clear_history()
st.session_state.chat_history = []
st.rerun()
with col2:
if st.button("Cancel", use_container_width=True):
st.rerun()
# ── Normal Chat ───────────────────────────────────────────────────────────────
def render_normal_chat():
"""Render the standard multi-turn chat interface.
Execution order on every rerun:
1. Apply model/token settings from the Settings panel (5e)
2. Consume any pending debug message from the editor (5f)
3. Replay chat history
4. Handle chat input with updated system-prompt logic (5g)
5. Render Clear Chat button and Settings expander (5d, 5h)
"""
chat_manager: ChatManager = st.session_state.chat_manager
# 5e — Apply model/token overrides from the Settings panel before any API call.
if st.session_state.get("selected_model"):
chat_manager.model = st.session_state.selected_model
if "chat_max_tokens" in st.session_state:
chat_manager.max_tokens = st.session_state.chat_max_tokens
# 5f — Consume a debug message forwarded from the editor's "Debug with AI" button.
pending_debug = st.session_state.pop("pending_debug_message", None)
if pending_debug:
if not chat_manager.get_history():
custom_prompt = st.session_state.get("custom_system_prompt", "").strip()
if custom_prompt:
chat_manager.add_message("system", custom_prompt)
else:
file_ctx = _build_file_context()
system_prompt = SystemPrompter.generate_prompt(file_ctx)
chat_manager.add_message("system", system_prompt)
with st.spinner("Sending debug info to AI..."):
try:
ai_response = chat_manager.send_message(pending_debug)
except Exception as e:
ai_response = f"Error: {e}"
st.session_state.chat_history.append({"role": "user", "content": pending_debug})
st.session_state.chat_history.append({"role": "assistant", "content": ai_response})
st.rerun()
return
# Replay the conversation history as chat bubbles (skip system messages).
for message in st.session_state.chat_history:
if message["role"] == "system":
continue
with st.chat_message(message["role"]):
st.markdown(message["content"])
# Chat input — Enter to send, no extra button needed.
user_input = st.chat_input("Type your message here...")
if user_input:
# 5g — System-prompt logic: inject on first message, update on file change.
if not chat_manager.get_history():
custom_prompt = st.session_state.get("custom_system_prompt", "").strip()
if custom_prompt:
chat_manager.add_message("system", custom_prompt)
else:
file_ctx = _build_file_context()
system_prompt = SystemPrompter.generate_prompt(file_ctx)
chat_manager.add_message("system", system_prompt)
elif st.session_state.get("active_file") and st.session_state.get("include_file_context", True):
# Follow-up messages: refresh the system prompt when the active file changes.
history = chat_manager.get_history()
if history and history[0]["role"] == "system":
file_ctx = _build_file_context()
if file_ctx:
history[0]["content"] = SystemPrompter.generate_prompt(file_ctx)
with st.chat_message("user"):
st.markdown(user_input)
with st.chat_message("assistant"):
with st.spinner("Thinking..."):
try:
ai_response = chat_manager.send_message(user_input)
except Exception as e:
ai_response = f"Error: {e}"
st.markdown(ai_response)
st.session_state.chat_history.append({"role": "user", "content": user_input})
st.session_state.chat_history.append({"role": "assistant", "content": ai_response})
st.rerun()
# 5d — Clear Chat opens a confirmation dialog instead of deleting immediately.
if st.button("🗑️ Clear Chat"):
_clear_chat_dialog()
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 ───────────────────────────────────────────────────────────────
def render_chat():
"""Top-level chat view — switches between Agent Mode and normal chat."""
if st.session_state.get("agent_mode", False):
st.subheader("Coding Agent")
render_agent_mode()
else:
st.subheader("Chat with AI Assistant")
render_normal_chat()
if __name__ == "__main__":
render_chat()