Add the assistant: a chat that drives the MCP tools in-process
Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
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. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fluksio.api.routes import (
|
||||
ai,
|
||||
alerts,
|
||||
artifacts,
|
||||
cloud,
|
||||
@@ -51,3 +52,6 @@ api_router.include_router(oauth.router)
|
||||
# Always mounted like oauth, so the generated SDK keeps its shape; the
|
||||
# endpoints refuse to work unless the private API is explicitly enabled.
|
||||
api_router.include_router(private.router)
|
||||
# The assistant. Always mounted for the same reason; without a provider
|
||||
# configured the status says so and the chat answers 404.
|
||||
api_router.include_router(ai.router)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""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"},
|
||||
)
|
||||
Reference in New Issue
Block a user