"""Chat-Seiten. Nachrichten werden als Run angelegt und im Worker beantwortet.""" import csv import io import re import uuid from typing import Annotated from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, Response from sqlalchemy import select from app import web from app.approvals.models import LABELS, ApprovalRequest from app.changes import service as changes from app.chat import service as chats from app.data_sources import service as sources from app.feedback import service as feedback from app.hugin import service as hugin from app.mcp import service as mcp from app.tasks import engine, states from app.workspaces import service as workspaces def router() -> APIRouter: r = APIRouter() @r.post("/workspaces/{workspace_id}/chats") def create(request: Request, principal: web.Checked, workspace_id: int, question: Annotated[str, Form()] = "") -> Response: """question: Beispielfrage aus der Übersicht, wird gleich gestellt.""" with web.db(request).session() as session: chat = chats.create(session, principal, workspace_id) if question.strip(): chats.send(session, principal, chat.id, question) return web.redirect(f"/chats/{chat.id}") @r.get("/chats/{chat_id}") def page(request: Request, principal: web.User, chat_id: int) -> HTMLResponse: with web.db(request).session() as session: chat = chats.get(session, principal, chat_id) context = { "chat": chat, "may_manage": chat.created_by == principal.user_id or workspaces.get(session, principal, chat.workspace_id).is_admin, "messages": chats.messages(session, principal, chat_id), "active": chats.active_run(session, chat_id), "sources": sources.list_for(session, principal, chat.workspace_id), "mcp_servers": [server for server, _ in mcp.enabled(session, chat.workspace_id)], "current_workspace": workspaces.get(session, principal, chat.workspace_id).workspace, } context["views"] = changes.views(session, web.settings(request), web.vault(request), chat.id) context["results"] = chats.results(session, [m.run_id for m in context["messages"] if m.role == "assistant"]) context["chat_usage"] = chats.session_usage(session, chat.id) context["examples"] = workspaces.examples(context["current_workspace"]) context["feedback"] = feedback.mine(session, principal.user_id, [m.id for m in context["messages"] if m.role == "assistant"]) context["commands"] = hugin.commands(session, chat.workspace_id) context["command_suggestions"] = hugin.suggestions(context["commands"]) last = context["messages"][-1] if context["messages"] else None context["retry"] = None if context["active"] or last is None else ( "Antwort neu erzeugen" if last.role == "assistant" else "Erneut versuchen") return web.render(request, "chat.html", context) @r.post("/chats/{chat_id}/rename") def rename(request: Request, principal: web.Checked, chat_id: int, title: Annotated[str, Form()]) -> Response: with web.db(request).session() as session: chats.rename(session, principal, chat_id, title) return web.redirect(f"/chats/{chat_id}") @r.post("/chats/{chat_id}/delete") def delete(request: Request, principal: web.Checked, chat_id: int) -> Response: with web.db(request).session() as session: workspace_id = chats.delete(session, principal, chat_id) return web.redirect(f"/workspaces/{workspace_id}") @r.post("/chats/{chat_id}/messages") def send(request: Request, principal: web.Checked, chat_id: int, content: Annotated[str, Form()]) -> HTMLResponse: with web.db(request).session() as session: message, run = chats.send(session, principal, chat_id, content) html = web.templates.get_template("_message.html").render(m=message) html += web.templates.get_template("_answer_stream.html").render(run=run) return HTMLResponse(html) @r.post("/chats/{chat_id}/ask") def ask(request: Request, principal: web.Checked, chat_id: int, content: Annotated[str, Form()]) -> Response: """Beispielfrage im leeren Chat: ohne HTMX, die Seite lädt mit laufender Antwort neu.""" with web.db(request).session() as session: chats.send(session, principal, chat_id, content) return web.redirect(f"/chats/{chat_id}") @r.get("/chats/{chat_id}/plan") def plan_panel(request: Request, principal: web.User, chat_id: int) -> HTMLResponse: with web.db(request).session() as session: chat = chats.get(session, principal, chat_id) return web.render(request, "_plan.html", {"chat": chat}) @r.post("/chats/{chat_id}/plan-mode") def plan_mode(request: Request, principal: web.Checked, chat_id: int, on: Annotated[str, Form()] = "") -> Response: with web.db(request).session() as session: chat = chats.set_plan_mode(session, principal, chat_id, on == "true") if web.is_htmx(request): # Plan-Ansicht ersetzen, den Schalter am Eingabefeld mit panel = web.render(request, "_plan.html", {"chat": chat}) toggle = web.templates.get_template("_plan_toggle.html").render(chat=chat, oob=True) return HTMLResponse(panel.body.decode() + toggle) return web.redirect(f"/chats/{chat_id}") @r.post("/chats/{chat_id}/plan/execute") def execute_plan(request: Request, principal: web.Checked, chat_id: int) -> Response: with web.db(request).session() as session: chats.execute_plan(session, principal, chat_id) return web.redirect(f"/chats/{chat_id}#messages-end") @r.post("/chats/{chat_id}/regenerate") def regenerate(request: Request, principal: web.Checked, chat_id: int) -> Response: with web.db(request).session() as session: chats.regenerate(session, principal, chat_id) return web.redirect(f"/chats/{chat_id}#messages-end") @r.get("/chats/{chat_id}/export.md") def export(request: Request, principal: web.User, chat_id: int) -> Response: with web.db(request).session() as session: chat, text = chats.export(session, principal, chat_id) filename = re.sub(r"[^A-Za-z0-9._-]+", "-", chat.title).strip("-")[:60] or f"chat-{chat_id}" return Response(text, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}.md"'}) @r.get("/tool-calls/{call_id}.csv") def result_csv(request: Request, principal: web.User, call_id: int) -> Response: """Ergebnis einer Abfrage als CSV (Semikolon, mit BOM für Excel), genau wie im Chat gezeigt.""" with web.db(request).session() as session: call = chats.tool_call(session, principal, call_id) result = call.result buffer = io.StringIO() writer = csv.writer(buffer, delimiter=";") writer.writerow(result["columns"]) writer.writerows([[("" if v is None else str(v).replace(".", ",") if isinstance(v, float) else v) for v in row] for row in result["rows"]]) return Response("\ufeff" + buffer.getvalue(), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="ergebnis-{call_id}.csv"'}) @r.get("/runs/{run_id}/answer") def answer(request: Request, principal: web.User, run_id: uuid.UUID) -> HTMLResponse: with web.db(request).session() as session: message = chats.answer(session, principal, run_id) run = engine.get(session, principal, run_id) if message is not None: return web.render(request, "_message.html", {"m": message, "latest": True, "results": chats.results(session, [message.run_id])}) if run.status in (states.QUEUED, states.RUNNING): # Nach einer Freigabe läuft der Run weiter: Stream wieder aufnehmen return web.render(request, "_answer_stream.html", {"run": run}) requests = list(session.scalars(select(ApprovalRequest).where(ApprovalRequest.run_id == run.id) .order_by(ApprovalRequest.id))) is_admin = workspaces.get(session, principal, run.workspace_id).is_admin return web.render(request, "_answer_end.html", {"run": run, "requests": requests, "is_admin": is_admin, "labels": LABELS}) return r