161 lines
8.8 KiB
Python
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
|