A provider set under Settings (OpenAI-compatible; key encrypted in flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs the FastMCP tools in-process as the person chatting, streams the rounds over SSE and keeps one transcript per user. The panel floats on any screen and shares the editor's lane: a settings panel keeps the top half, the chat the bottom. The MCP client is now built once, for the endpoint and the chat.
251 lines
7.8 KiB
Python
251 lines
7.8 KiB
Python
"""The assistant: its provider, and the conversation with it.
|
|
|
|
The provider is instance-wide and superuser-only to set, because the key it
|
|
holds is spent by everyone who chats. The conversation is per person and runs
|
|
as them: every tool call carries their own token, so the assistant can do
|
|
nothing they could not do in the browser.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from fluksio.ai import agent, client
|
|
from fluksio.ai import config as ai_config
|
|
from fluksio.api.deps import CurrentUser, TokenDep, get_current_active_superuser
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.schemas import NAME_PATTERN
|
|
from fluksio.mcp import server as tools
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/ai", tags=["ai"])
|
|
|
|
#: Whose turn is running: a second message while one is answered is refused
|
|
#: rather than interleaved into the same transcript.
|
|
_busy: set[uuid.UUID] = set()
|
|
|
|
|
|
class AIConfigPublic(BaseModel):
|
|
api_base: str
|
|
model: str
|
|
max_rounds: int
|
|
api_key_set: bool
|
|
api_key_masked: str | None
|
|
|
|
|
|
class AIConfigUpdate(BaseModel):
|
|
api_base: str = Field(min_length=1, max_length=512)
|
|
#: Blank or absent keeps the stored key.
|
|
api_key: str | None = Field(default=None, max_length=512)
|
|
model: str = Field(default="", max_length=128)
|
|
max_rounds: int = Field(default=12, ge=1, le=50)
|
|
|
|
|
|
class AIModelsBody(BaseModel):
|
|
api_base: str = Field(min_length=1, max_length=512)
|
|
#: Absent tries the stored key, so a saved provider can be re-listed.
|
|
api_key: str | None = Field(default=None, max_length=512)
|
|
|
|
|
|
class AIStatus(BaseModel):
|
|
available: bool
|
|
model: str | None
|
|
|
|
|
|
class ChatItem(BaseModel):
|
|
role: str
|
|
content: str | None = None
|
|
tool_calls: list[dict[str, Any]] | None = None
|
|
#: On a tool's answer: whether it worked, and the error if not.
|
|
ok: bool | None = None
|
|
error: str | None = None
|
|
|
|
|
|
class TurnBody(BaseModel):
|
|
message: str = Field(min_length=1, max_length=20_000)
|
|
#: The flow open in the editor, when the chat is opened from one.
|
|
flow: str | None = Field(default=None, pattern=NAME_PATTERN.pattern)
|
|
|
|
|
|
def _public(config: ai_config.AIConfig | None) -> AIConfigPublic:
|
|
if config is None:
|
|
return AIConfigPublic(
|
|
api_base="",
|
|
model="",
|
|
max_rounds=12,
|
|
api_key_set=False,
|
|
api_key_masked=None,
|
|
)
|
|
return AIConfigPublic(**config.public())
|
|
|
|
|
|
@router.get(
|
|
"/config",
|
|
dependencies=[Depends(get_current_active_superuser)],
|
|
response_model=AIConfigPublic,
|
|
)
|
|
def read_ai_config() -> Any:
|
|
return _public(ai_config.load())
|
|
|
|
|
|
@router.put(
|
|
"/config",
|
|
dependencies=[Depends(get_current_active_superuser)],
|
|
response_model=AIConfigPublic,
|
|
)
|
|
def update_ai_config(body: AIConfigUpdate) -> Any:
|
|
try:
|
|
client.check_base(body.api_base)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
current = ai_config.load()
|
|
key = body.api_key or (current.api_key if current else "")
|
|
config = ai_config.AIConfig(
|
|
api_base=body.api_base.rstrip("/"),
|
|
api_key=key,
|
|
model=body.model.strip(),
|
|
max_rounds=body.max_rounds,
|
|
)
|
|
ai_config.save(config)
|
|
return _public(config)
|
|
|
|
|
|
@router.delete("/config", dependencies=[Depends(get_current_active_superuser)])
|
|
def delete_ai_config() -> Any:
|
|
ai_config.delete()
|
|
return _public(None)
|
|
|
|
|
|
@router.post(
|
|
"/config/models",
|
|
dependencies=[Depends(get_current_active_superuser)],
|
|
response_model=list[str],
|
|
)
|
|
async def list_ai_models(body: AIModelsBody) -> Any:
|
|
"""What the provider offers, tried with the key given or the one stored."""
|
|
try:
|
|
client.check_base(body.api_base)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
current = ai_config.load()
|
|
key = body.api_key or (current.api_key if current else "")
|
|
try:
|
|
return await client.list_models(body.api_base.rstrip("/"), key)
|
|
except (client.ProviderError, httpx.HTTPError) as exc:
|
|
raise HTTPException(
|
|
status_code=400, detail=f"The provider answered {exc}"
|
|
) from exc
|
|
|
|
|
|
@router.get("/status", response_model=AIStatus)
|
|
def read_ai_status(_: CurrentUser) -> Any:
|
|
"""Whether there is an assistant to show. Readable by anyone signed in."""
|
|
config = ai_config.load()
|
|
if config is None or not config.ready:
|
|
return AIStatus(available=False, model=None)
|
|
return AIStatus(available=True, model=config.model)
|
|
|
|
|
|
def _chat_file(user_id: uuid.UUID) -> Any:
|
|
return settings.CHATS_DIR / f"{user_id}.json"
|
|
|
|
|
|
def _load_chat(user_id: uuid.UUID) -> list[dict[str, Any]]:
|
|
try:
|
|
messages: list[dict[str, Any]] = json.loads(_chat_file(user_id).read_text())
|
|
except (OSError, ValueError):
|
|
return []
|
|
return messages
|
|
|
|
|
|
def _save_chat(user_id: uuid.UUID, messages: list[dict[str, Any]]) -> None:
|
|
path = _chat_file(user_id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(messages))
|
|
|
|
|
|
@router.get("/chat", response_model=list[ChatItem])
|
|
def read_chat(user: CurrentUser) -> Any:
|
|
"""The transcript, with each tool's answer reduced to whether it worked."""
|
|
items = []
|
|
for message in _load_chat(user.id):
|
|
if message.get("role") == "tool":
|
|
items.append(
|
|
ChatItem(role="tool", **agent.summarize(message.get("content")))
|
|
)
|
|
else:
|
|
items.append(
|
|
ChatItem(
|
|
role=message["role"],
|
|
content=message.get("content"),
|
|
tool_calls=message.get("tool_calls"),
|
|
)
|
|
)
|
|
return items
|
|
|
|
|
|
@router.delete("/chat", response_model=list[ChatItem])
|
|
def clear_chat(user: CurrentUser) -> Any:
|
|
_chat_file(user.id).unlink(missing_ok=True)
|
|
return []
|
|
|
|
|
|
def _sse(event: dict[str, Any]) -> str:
|
|
return f"data: {json.dumps(event)}\n\n"
|
|
|
|
|
|
@router.post("/chat")
|
|
def chat(
|
|
body: TurnBody, request: Request, user: CurrentUser, token: TokenDep
|
|
) -> StreamingResponse:
|
|
"""One turn, streamed as server-sent events.
|
|
|
|
``message``, ``tool_call`` and ``tool_result`` events as the model works,
|
|
``error`` when something stops it, and ``done`` last either way.
|
|
"""
|
|
config = ai_config.load()
|
|
if config is None or not config.ready:
|
|
# 404 rather than 403: an unconfigured instance says nothing about it.
|
|
raise HTTPException(status_code=404, detail="The assistant is not set up")
|
|
if user.id in _busy:
|
|
raise HTTPException(status_code=409, detail="Still answering the last one")
|
|
tools.connect(request.app)
|
|
messages = _load_chat(user.id)
|
|
|
|
async def generate() -> AsyncIterator[str]:
|
|
_busy.add(user.id)
|
|
try:
|
|
yield _sse({"type": "start"})
|
|
async for event in agent.run_turn(
|
|
config,
|
|
messages,
|
|
body.message,
|
|
flow=body.flow,
|
|
authorization=f"Bearer {token}",
|
|
):
|
|
yield _sse(event)
|
|
except Exception as exc: # noqa: BLE001 - the stream is the error channel
|
|
logger.exception("The assistant's turn failed")
|
|
yield _sse({"type": "error", "detail": f"{type(exc).__name__}: {exc}"})
|
|
finally:
|
|
# What happened so far, even if the browser left part-way.
|
|
_save_chat(user.id, messages)
|
|
_busy.discard(user.id)
|
|
yield _sse({"type": "done"})
|
|
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|