bifroest/app/chat/routes.py
2026-10-27 08:34:17 +01:00

161 lines
8.8 KiB
Python

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