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

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:
2026-09-16 23:28:23 +02:00
co-authored by Claude Fable 5.1
parent d9630f66f5
commit cc4091166e
25 changed files with 1957 additions and 43 deletions
+7
View File
@@ -0,0 +1,7 @@
"""The assistant: a model driving the MCP tools from inside the dashboard.
``config`` is the provider and the key, ``client`` the one call every
OpenAI-compatible provider answers, ``agent`` the loop between the model and
the tools. The tools are the MCP server's own, so what the assistant can do is
exactly what an external agent can do.
"""
+172
View File
@@ -0,0 +1,172 @@
"""The turn: the model, the tools, and the rounds between them.
The tools are the MCP server's own, called in-process rather than over the
endpoint: same names, same schemas, same REST calls underneath. A tool added
for external agents is there for the assistant the moment it exists, and the
assistant can do nothing an external agent could not.
The transcript is a list of chat-completion messages, mutated in place by
:func:`run_turn` and persisted by the caller. It is also what the browser
renders, so nothing is stored twice.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from typing import Any
import httpx
from mcp.types import TextContent
from fluksio.ai import client
from fluksio.ai.config import AIConfig
from fluksio.flow.schemas import FlowDef
from fluksio.mcp.server import caller_authorization, mcp
logger = logging.getLogger(__name__)
_SYSTEM = """You are the assistant inside a Fluksio instance. You read, build, edit and \
operate flows through the tools; they are the same API the editor uses, so \
anything you save appears in the editor at once.
How to work:
- Read before you write: get_flow before save_flow, and pass back the version \
you read. On a version conflict, read again and reapply the change.
- save_flow writes a draft, and validate_flow tells you whether it is sound. \
Publish only when the user asks for the change to go live; otherwise leave \
the draft for review in the editor and say so.
- list_node_types gives the node types and their parameters; get_node_source \
shows how a Python node is written before you write one.
- Keep answers short, in plain text without markdown. Name what you changed.
The flow document `save_flow` takes, as a JSON schema:
"""
# ponytail: a tool's answer is replayed on every later round of the
# conversation, so one big one is paid for again and again; cut it here rather
# than summarise it. A per-tool digest is the upgrade if this bites.
_RESULT_LIMIT = 20_000
def system_prompt(flow: str | None) -> str:
prompt = _SYSTEM + json.dumps(FlowDef.model_json_schema())
if flow:
prompt += f"\n\nThe user is looking at the flow named {flow!r}."
return prompt
async def tool_specs() -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description or "",
"parameters": tool.inputSchema,
},
}
for tool in await mcp.list_tools()
]
def _arguments(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
try:
parsed = json.loads(raw or "{}")
except ValueError:
return {}
return parsed if isinstance(parsed, dict) else {}
async def _call_tool(name: str, arguments: dict[str, Any]) -> str:
"""Run one tool; whatever goes wrong is text the model can read."""
try:
result = await mcp.call_tool(name, arguments)
except Exception as exc: # noqa: BLE001 - an unknown tool, or refused arguments
return json.dumps({"error": str(exc)})
if isinstance(result, dict):
text = json.dumps(result)
else:
text = "".join(c.text for c in result if isinstance(c, TextContent))
if len(text) > _RESULT_LIMIT:
text = text[:_RESULT_LIMIT] + "… (truncated)"
return text
def summarize(content: str | None) -> dict[str, Any]:
"""What the browser shows for a tool's answer: whether it worked."""
try:
result = json.loads(content or "")
except ValueError:
return {"ok": True}
if isinstance(result, dict) and "error" in result:
return {"ok": False, "error": str(result["error"])[:300]}
return {"ok": True}
async def run_turn(
config: AIConfig,
messages: list[dict[str, Any]],
text: str,
*,
flow: str | None,
authorization: str,
) -> AsyncIterator[dict[str, Any]]:
"""One user turn, yielding progress events as it goes.
A round is appended to *messages* only once every tool in it has answered,
so a turn cut off part-way leaves a transcript the provider still accepts.
"""
messages.append({"role": "user", "content": text})
tools = await tool_specs()
system = {"role": "system", "content": system_prompt(flow)}
token = caller_authorization.set(authorization)
try:
for _ in range(config.max_rounds):
try:
response = await client.chat_completion(
api_base=config.api_base,
api_key=config.api_key,
model=config.model,
messages=[system, *messages],
tools=tools,
)
except (client.ProviderError, httpx.HTTPError) as exc:
yield {"type": "error", "detail": f"The provider answered {exc}"}
return
choice = response["choices"][0]["message"]
content = choice.get("content") or ""
calls = choice.get("tool_calls") or []
assistant: dict[str, Any] = {"role": "assistant", "content": content}
if content:
yield {"type": "message", "content": content}
if not calls:
messages.append(assistant)
return
assistant["tool_calls"] = calls
answers: list[dict[str, Any]] = []
for call in calls:
function = call.get("function") or {}
name = str(function.get("name", ""))
arguments = _arguments(function.get("arguments"))
yield {"type": "tool_call", "name": name, "arguments": arguments}
result = await _call_tool(name, arguments)
answers.append(
{
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": result,
}
)
yield {"type": "tool_result", "name": name, **summarize(result)}
messages.append(assistant)
messages.extend(answers)
yield {
"type": "error",
"detail": f"Stopped after {config.max_rounds} rounds of tool calls.",
}
finally:
caller_authorization.reset(token)
+76
View File
@@ -0,0 +1,76 @@
"""One OpenAI-compatible provider call, and the model listing beside it.
Chat completions with function tools is the dialect every hosted provider and
every local server speaks — OpenAI, Anthropic's compatibility endpoint,
OpenRouter, Ollama, vLLM — so one client covers them without a provider SDK.
The admin types the URL; the model never chooses where a request goes.
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
import httpx
class ProviderError(Exception):
"""The provider answered, and the answer was a refusal."""
def check_base(api_base: str) -> None:
parsed = urlparse(api_base)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError("The provider URL must be an http(s) URL")
def _headers(api_key: str) -> dict[str, str]:
return {"Authorization": f"Bearer {api_key}"}
def _detail(response: httpx.Response) -> str:
try:
error = response.json().get("error")
except ValueError:
error = None
if isinstance(error, dict):
error = error.get("message")
text = str(error or response.text or response.reason_phrase)
return f"{response.status_code}: {text[:300]}"
async def chat_completion(
*,
api_base: str,
api_key: str,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
timeout: float = 120.0,
) -> dict[str, Any]:
"""``POST {api_base}/chat/completions``, the whole answer at once."""
body: dict[str, Any] = {"model": model, "messages": messages}
if tools:
body["tools"] = tools
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.post(
api_base.rstrip("/") + "/chat/completions",
json=body,
headers=_headers(api_key),
)
if response.is_error:
raise ProviderError(_detail(response))
result: dict[str, Any] = response.json()
return result
async def list_models(api_base: str, api_key: str, timeout: float = 15.0) -> list[str]:
"""The ids ``GET {api_base}/models`` offers, for the settings screen."""
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.get(
api_base.rstrip("/") + "/models", headers=_headers(api_key)
)
if response.is_error:
raise ProviderError(_detail(response))
data = response.json().get("data") or []
return sorted(str(m["id"]) for m in data if isinstance(m, dict) and "id" in m)
+98
View File
@@ -0,0 +1,98 @@
"""The assistant's provider: where the model is, and the key that opens it.
One file on the data volume beside ``cloud.json``, for the same reasons: it is
a credential, it must survive a rebuild, and it must never be in the flows
repository. The key is encrypted with the cipher the secrets store uses, so
both turn unreadable together when ``SECRET_KEY`` changes. Nothing here exists
until a superuser writes it, and its absence is what keeps every AI surface
off.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import asdict, dataclass
from typing import Any
from cryptography.fernet import InvalidToken
from fluksio.core.config import settings
from fluksio.flow.secrets import fernet
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class AIConfig:
#: An OpenAI-compatible base, e.g. ``https://api.openai.com/v1``.
api_base: str
api_key: str
model: str
#: How many model-and-tools rounds one turn may take before it is stopped.
max_rounds: int = 12
@property
def ready(self) -> bool:
return bool(self.api_base and self.api_key and self.model)
def public(self) -> dict[str, Any]:
"""The secret-free view the settings screen gets."""
key = self.api_key
masked = None
if key:
masked = f"{key[:3]}…{key[-4:]}" if len(key) > 8 else "••••"
return {
"api_base": self.api_base,
"model": self.model,
"max_rounds": self.max_rounds,
"api_key_set": bool(key),
"api_key_masked": masked,
}
def load() -> AIConfig | None:
path = settings.AI_CONFIG_FILE
try:
data = json.loads(path.read_text())
except OSError:
return None
except ValueError:
logger.exception("Could not read %s; the assistant stays off", path)
return None
sealed = data.pop("api_key_enc", "")
key = ""
if sealed:
try:
key = fernet(settings.SECRET_KEY).decrypt(sealed.encode()).decode()
except InvalidToken:
logger.error(
"%s was written with a different SECRET_KEY; enter the key again",
path,
)
try:
return AIConfig(api_key=key, **data)
except TypeError:
logger.exception("Could not read %s; the assistant stays off", path)
return None
def save(config: AIConfig) -> None:
path = settings.AI_CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
data = asdict(config)
key = data.pop("api_key")
data["api_key_enc"] = (
fernet(settings.SECRET_KEY).encrypt(key.encode()).decode() if key else ""
)
# Written through a temporary file so a crash mid-write cannot leave a
# half-parsed credential behind, and never group- or world-readable.
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2))
os.chmod(tmp, 0o600)
tmp.replace(path)
def delete() -> None:
settings.AI_CONFIG_FILE.unlink(missing_ok=True)
+4
View File
@@ -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)
+250
View File
@@ -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"},
)
+8
View File
@@ -27,6 +27,8 @@ DERIVED_PATHS = {
"OAUTH_PRIVATE_KEY_FILE": "oauth-key.pem",
"CLOUD_CONFIG_FILE": "cloud.json",
"PROVISIONERS_FILE": "provisioners.json",
"AI_CONFIG_FILE": "ai.json",
"CHATS_DIR": "chats",
}
@@ -102,6 +104,12 @@ class Settings(BaseSettings):
# Written only when someone enrols this instance with a portal.
# Its absence is what keeps remote access off.
CLOUD_CONFIG_FILE: Path = Path("flow-data/cloud.json")
# The assistant's provider: written from Settings, key encrypted with the
# app key like the secrets store. Absent until someone configures it, and
# its absence is what keeps every AI surface off.
AI_CONFIG_FILE: Path = Path("flow-data/ai.json")
# One transcript per user, beside the config it talks through.
CHATS_DIR: Path = Path("flow-data/chats")
OAUTH_CODE_EXPIRE_SECONDS: int = 60
# Short, because an agent's token is a bearer secret held by a program
# rather than a person, and it can refresh unattended.
+7 -3
View File
@@ -34,14 +34,18 @@ class SecretNotFound(KeyError):
return f"No secret named '{self.name}' — add it under Secrets."
def fernet(key: str) -> Fernet:
"""The cipher a store keyed on the app key uses, so every one of them
turns unreadable together when ``SECRET_KEY`` changes."""
return Fernet(base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()))
class SecretsStore:
"""Named credentials, encrypted at rest with a key derived from the app key."""
def __init__(self, path: Path, key: str) -> None:
self._path = path
self._fernet = Fernet(
base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest())
)
self._fernet = fernet(key)
def _read(self) -> dict[str, str]:
if not self._path.exists():
+6 -4
View File
@@ -3,6 +3,7 @@ import contextlib
import hashlib
import inspect
import logging
import sys
import tempfile
from collections.abc import AsyncIterator, Callable, Coroutine
from contextlib import AbstractAsyncContextManager, asynccontextmanager
@@ -392,10 +393,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
raise
finally:
await _release()
if settings.MCP_ENABLED:
from fluksio.mcp.http import aclose
await aclose()
# Imported by the endpoint when MCP is on and by the assistant on its
# first turn; either way the client it built is closed here.
tools = sys.modules.get("fluksio.mcp.server")
if tools is not None:
await tools.aclose()
# The schema enumerates every endpoint this instance serves, including the
+1 -20
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
import logging
from urllib.parse import urlparse
import httpx
import jwt
from fastapi.concurrency import run_in_threadpool
from mcp.server.auth.provider import AccessToken
@@ -27,9 +26,6 @@ from fluksio.mcp import server
logger = logging.getLogger(__name__)
#: The tool calls never leave the process, so the host is a label, not a route.
_INTERNAL_BASE = "http://fluksio-mcp.internal"
class _JWTVerifier:
"""Accept only tokens minted for the MCP channel, by agents still registered.
@@ -112,20 +108,5 @@ def build_http_app(app: object) -> Starlette:
# module-level so the tools can be declared at import time.
server.mcp._token_verifier = _JWTVerifier() # noqa: SLF001
server.set_client(
httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
base_url=_INTERNAL_BASE,
# Running a flow is synchronous work on a threadpool, so a slow
# flow must not look like a dead endpoint.
timeout=120.0,
event_hooks={"request": [server.forward_caller_auth]},
)
)
server.connect(app)
return server.mcp.streamable_http_app()
async def aclose() -> None:
if server._client is not None: # noqa: SLF001
await server._client.aclose() # noqa: SLF001
server.set_client(None)
+45 -9
View File
@@ -13,6 +13,7 @@ identity rather than some service account.
from __future__ import annotations
import logging
from contextvars import ContextVar
from typing import Any
import httpx
@@ -23,28 +24,63 @@ logger = logging.getLogger(__name__)
mcp = FastMCP("fluksio")
#: Set by ``http.build_http_app``; the ASGI client that carries tool calls into
#: the REST API without a network hop.
#: The tool calls never leave the process, so the host is a label, not a route.
_INTERNAL_BASE = "http://fluksio-mcp.internal"
#: The ASGI client that carries tool calls into the REST API without a network
#: hop. Built once by :func:`connect`, for the endpoint and the assistant alike.
_client: httpx.AsyncClient | None = None
#: The ``Authorization`` value for tool calls made from inside this process,
#: where no MCP request carries one: the assistant sets it to the token of the
#: person chatting, for the length of their turn.
caller_authorization: ContextVar[str | None] = ContextVar(
"caller_authorization", default=None
)
def set_client(client: httpx.AsyncClient | None) -> None:
global _client
_client = client
async def forward_caller_auth(request: httpx.Request) -> None:
"""Carry the calling agent's bearer token onto the REST call.
def connect(app: object) -> None:
"""Wire the tools to *app*'s API, once."""
if _client is not None:
return
set_client(
httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
base_url=_INTERNAL_BASE,
# Running a flow is synchronous work on a threadpool, so a slow
# flow must not look like a dead endpoint.
timeout=120.0,
event_hooks={"request": [forward_caller_auth]},
)
)
The transport attaches the originating HTTP request to every JSON-RPC
message, and a tool handler runs inside that request's context, so the
token is read per message rather than per connection.
async def aclose() -> None:
if _client is not None:
await _client.aclose()
set_client(None)
async def forward_caller_auth(request: httpx.Request) -> None:
"""Carry the caller's bearer token onto the REST call.
Over the endpoint, the transport attaches the originating HTTP request to
every JSON-RPC message, and a tool handler runs inside that request's
context, so the token is read per message rather than per connection. A
call from inside the process has no such request and uses what
:data:`caller_authorization` holds.
"""
authorization = caller_authorization.get()
try:
source = request_ctx.get().request
except LookupError:
return
authorization = getattr(source, "headers", {}).get("authorization")
source = None
authorization = getattr(source, "headers", {}).get("authorization") or authorization
if authorization:
request.headers["authorization"] = authorization
+138
View File
@@ -0,0 +1,138 @@
"""The assistant: who may set it up, and that it acts as the person chatting."""
import json
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from fluksio.ai import agent
from fluksio.core.config import settings
@pytest.fixture
def ai_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(settings, "AI_CONFIG_FILE", tmp_path / "ai.json")
monkeypatch.setattr(settings, "CHATS_DIR", tmp_path / "chats")
return tmp_path
def _events(response: Any) -> list[dict[str, Any]]:
return [
json.loads(line[5:])
for line in response.iter_lines()
if line.startswith("data:")
]
def test_the_provider_is_a_superuser_setting_and_the_key_stays_sealed(
client: TestClient,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
ai_files: Path,
) -> None:
assert client.get(
"/api/v1/ai/status", headers=normal_user_token_headers
).json() == {
"available": False,
"model": None,
}
body = {"api_base": "http://llm.test/v1", "api_key": "sk-test-123456", "model": "m"}
refused = client.put(
"/api/v1/ai/config", json=body, headers=normal_user_token_headers
)
assert refused.status_code == 403
saved = client.put("/api/v1/ai/config", json=body, headers=superuser_token_headers)
assert saved.status_code == 200, saved.text
assert saved.json()["api_key_masked"] == "sk-…3456"
assert "sk-test" not in (ai_files / "ai.json").read_text()
assert client.get(
"/api/v1/ai/status", headers=normal_user_token_headers
).json() == {
"available": True,
"model": "m",
}
# A save that names no key keeps the one there is.
kept = client.put(
"/api/v1/ai/config",
json={"api_base": "http://llm.test/v1", "model": "m2"},
headers=superuser_token_headers,
)
assert kept.json()["api_key_set"] is True
assert kept.json()["model"] == "m2"
@pytest.mark.usefixtures("ai_files")
def test_a_turn_runs_the_tools_as_the_person_chatting(
client: TestClient,
superuser_token_headers: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
client.put(
"/api/v1/ai/config",
json={"api_base": "http://llm.test/v1", "api_key": "k", "model": "m"},
headers=superuser_token_headers,
)
seen: list[list[dict[str, Any]]] = []
answers: Iterator[dict[str, Any]] = iter(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "list_flows", "arguments": "{}"},
}
],
},
{"role": "assistant", "content": "Counted them."},
]
)
async def fake_completion(**kwargs: Any) -> dict[str, Any]:
seen.append(kwargs["messages"])
assert any(t["function"]["name"] == "save_flow" for t in kwargs["tools"])
return {"choices": [{"message": next(answers)}]}
monkeypatch.setattr(agent.client, "chat_completion", fake_completion)
with client.stream(
"POST",
"/api/v1/ai/chat",
json={"message": "How many flows are there?"},
headers=superuser_token_headers,
) as response:
assert response.status_code == 200
events = _events(response)
assert [e["type"] for e in events] == [
"start",
"tool_call",
"tool_result",
"message",
"done",
]
# The tool went through the REST API with the caller's own token: an
# unauthenticated call would have come back as an error the model reads.
assert events[2] == {"type": "tool_result", "name": "list_flows", "ok": True}
answered = seen[-1][-1]
assert answered["role"] == "tool"
assert "count" in json.loads(answered["content"])
assert seen[-1][0]["role"] == "system"
transcript = client.get("/api/v1/ai/chat", headers=superuser_token_headers).json()
assert [m["role"] for m in transcript] == ["user", "assistant", "tool", "assistant"]
assert transcript[2] == {
"role": "tool",
"content": None,
"tool_calls": None,
"ok": True,
"error": None,
}
client.delete("/api/v1/ai/chat", headers=superuser_token_headers)
assert client.get("/api/v1/ai/chat", headers=superuser_token_headers).json() == []
+19
View File
@@ -95,6 +95,25 @@ door could write one the canvas cannot open.
The caller's token rides along on every hop, so the API sees the agent's own
identity rather than a service account. The audit trail on Home names it.
## The assistant
The same tools drive the chat inside the dashboard. An operator sets a
provider under **Settings → Assistant** (anything OpenAI-compatible: OpenAI,
Anthropic, OpenRouter, a local Ollama), and an **Assistant** entry appears in
the sidebar for everyone. The panel opens on any screen and keeps its place
from screen to screen; on a flow, it knows which one you are looking at.
The model calls the tools above, in-process, as **you**: every call carries
your own token, so the chat can do nothing you could not do yourself, and its
edits show up in the editor as drafts the moment they are saved. It is told to
publish only when asked, but it has the tool, so read what it did before
saying so. The trace under each answer lists every call it made.
The conversation is one per person, kept on the instance beside the
configuration, and the bin in the panel's header starts it over. `MCP_ENABLED`
is not needed for it: the endpoint is for agents outside, the chat for the
one inside.
## A note on working this way
The pattern that works is the same one that works with a person: let the agent
+1 -1
View File
@@ -25,7 +25,7 @@ phone the sidebar collapses to a sheet.
| **Alerts** | where failures get sent |
| **Admin** | users (superusers only) |
| **Search** | anything in this instance, by name |
| **Settings** | your account, appearance, and remote access |
| **Settings** | your account, appearance, remote access, and the assistant |
### Search
+7
View File
@@ -129,3 +129,10 @@ follow the system).
**Remote access** is where an instance is paired with a portal, remote
users are admitted, and the link is cut again. That has [its own
page](portal.md).
**Assistant** turns on the chat that builds and edits flows: pick a provider,
paste a key, fetch the model list and choose one. The key is stored encrypted
on the instance and never shown again. One provider serves everyone who signs
in here, and until one is set nothing in the interface mentions the feature.
How the chat works, and what it may do, is on the [agents
page](../code/agents.md#the-assistant).
+209
View File
@@ -1,5 +1,128 @@
// This file is auto-generated by @hey-api/openapi-ts
export const AIConfigPublicSchema = {
properties: {
api_base: {
type: 'string',
title: 'Api Base'
},
model: {
type: 'string',
title: 'Model'
},
max_rounds: {
type: 'integer',
title: 'Max Rounds'
},
api_key_set: {
type: 'boolean',
title: 'Api Key Set'
},
api_key_masked: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Api Key Masked'
}
},
type: 'object',
required: ['api_base', 'model', 'max_rounds', 'api_key_set', 'api_key_masked'],
title: 'AIConfigPublic'
} as const;
export const AIConfigUpdateSchema = {
properties: {
api_base: {
type: 'string',
maxLength: 512,
minLength: 1,
title: 'Api Base'
},
api_key: {
anyOf: [
{
type: 'string',
maxLength: 512
},
{
type: 'null'
}
],
title: 'Api Key'
},
model: {
type: 'string',
maxLength: 128,
title: 'Model',
default: ''
},
max_rounds: {
type: 'integer',
maximum: 50,
minimum: 1,
title: 'Max Rounds',
default: 12
}
},
type: 'object',
required: ['api_base'],
title: 'AIConfigUpdate'
} as const;
export const AIModelsBodySchema = {
properties: {
api_base: {
type: 'string',
maxLength: 512,
minLength: 1,
title: 'Api Base'
},
api_key: {
anyOf: [
{
type: 'string',
maxLength: 512
},
{
type: 'null'
}
],
title: 'Api Key'
}
},
type: 'object',
required: ['api_base'],
title: 'AIModelsBody'
} as const;
export const AIStatusSchema = {
properties: {
available: {
type: 'boolean',
title: 'Available'
},
model: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Model'
}
},
type: 'object',
required: ['available', 'model'],
title: 'AIStatus'
} as const;
export const AlertsConfigSchema = {
properties: {
enabled: {
@@ -382,6 +505,66 @@ export const ChannelSchema = {
description: 'Somewhere to send an alert.'
} as const;
export const ChatItemSchema = {
properties: {
role: {
type: 'string',
title: 'Role'
},
content: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Content'
},
tool_calls: {
anyOf: [
{
items: {
additionalProperties: true,
type: 'object'
},
type: 'array'
},
{
type: 'null'
}
],
title: 'Tool Calls'
},
ok: {
anyOf: [
{
type: 'boolean'
},
{
type: 'null'
}
],
title: 'Ok'
},
error: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Error'
}
},
type: 'object',
required: ['role'],
title: 'ChatItem'
} as const;
export const DTypeSchema = {
type: 'string',
enum: ['float', 'int', 'str', 'bool', 'json', 'series', 'record', 'list', 'artifact', 'image', 'audio', 'video'],
@@ -3608,6 +3791,32 @@ export const TriggerRequestSchema = {
title: 'TriggerRequest'
} as const;
export const TurnBodySchema = {
properties: {
message: {
type: 'string',
maxLength: 20000,
minLength: 1,
title: 'Message'
},
flow: {
anyOf: [
{
type: 'string',
pattern: '^[a-z][a-z0-9_]*$'
},
{
type: 'null'
}
],
title: 'Flow'
}
},
type: 'object',
required: ['message'],
title: 'TurnBody'
} as const;
export const UnsubscribeSchema = {
properties: {
endpoint: {
File diff suppressed because one or more lines are too long
+68
View File
@@ -1,5 +1,30 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AIConfigPublic = {
api_base: string;
model: string;
max_rounds: number;
api_key_set: boolean;
api_key_masked: (string | null);
};
export type AIConfigUpdate = {
api_base: string;
api_key?: (string | null);
model?: string;
max_rounds?: number;
};
export type AIModelsBody = {
api_base: string;
api_key?: (string | null);
};
export type AIStatus = {
available: boolean;
model: (string | null);
};
/**
* The whole alerting setup, as stored and as the API sees it.
*/
@@ -107,6 +132,16 @@ export type Channel = {
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush';
export type ChatItem = {
role: string;
content?: (string | null);
tool_calls?: (Array<{
[key: string]: unknown;
}> | null);
ok?: (boolean | null);
error?: (string | null);
};
/**
* A dashboard as stored, and as the API hands it over.
*/
@@ -1236,6 +1271,11 @@ export type TriggerRequest = {
};
};
export type TurnBody = {
message: string;
flow?: (string | null);
};
export type Unsubscribe = {
endpoint: string;
};
@@ -1401,6 +1441,34 @@ export type WorkerInfo = {
ram_mb?: (number | null);
};
export type AiReadAiConfigResponse = (AIConfigPublic);
export type AiUpdateAiConfigData = {
requestBody: AIConfigUpdate;
};
export type AiUpdateAiConfigResponse = (AIConfigPublic);
export type AiDeleteAiConfigResponse = (unknown);
export type AiListAiModelsData = {
requestBody: AIModelsBody;
};
export type AiListAiModelsResponse = (Array<(string)>);
export type AiReadAiStatusResponse = (AIStatus);
export type AiReadChatResponse = (Array<ChatItem>);
export type AiChatData = {
requestBody: TurnBody;
};
export type AiChatResponse = (unknown);
export type AiClearChatResponse = (Array<ChatItem>);
export type AlertsReadAlertsConfigResponse = (AlertsConfig);
export type AlertsSaveAlertsConfigData = {
+307
View File
@@ -0,0 +1,307 @@
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"
import { useRouterState } from "@tanstack/react-router"
import { Bot, Send, Square, Trash2 } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { AiService, type ChatItem } from "@/client"
import { flowKeys } from "@/components/Flow/queries"
import { SidePanel } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { FluksioLoader } from "@/components/ui/fluksio-loader"
import { Input } from "@/components/ui/input"
import { Segmented } from "@/components/ui/segmented"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import { chatStore, useChat } from "./chatStore"
import { type ChatEvent, streamTurn } from "./stream"
export const aiStatusQueryOptions = () =>
queryOptions({
queryKey: ["ai", "status"],
queryFn: () => AiService.readAiStatus(),
staleTime: 60_000,
})
/**
* What the panel is for. One tab today; the second, talking to a wren node in
* a flow, joins here when that node exists, and the strip only draws once
* there is a choice to make.
*/
const TABS = [["build", "Build"]] as const
type Item =
| { kind: "user" | "assistant" | "error"; text: string }
| {
kind: "tool"
name: string
args?: Record<string, unknown>
ok?: boolean
error?: string
}
/** The stored transcript, in the shape the live turn appends. */
function fromTranscript(transcript: ChatItem[]): Item[] {
const items: Item[] = []
// A tool's answer follows its call; the earliest unanswered call is its.
const pending: number[] = []
for (const message of transcript) {
if (message.role === "user" && message.content) {
items.push({ kind: "user", text: message.content })
} else if (message.role === "assistant") {
if (message.content)
items.push({ kind: "assistant", text: message.content })
for (const call of message.tool_calls ?? []) {
const fn = (call.function ?? {}) as {
name?: string
arguments?: string
}
let args: Record<string, unknown> | undefined
try {
args = JSON.parse(fn.arguments ?? "{}") as Record<string, unknown>
} catch {
args = undefined
}
pending.push(items.length)
items.push({ kind: "tool", name: fn.name ?? "tool", args })
}
} else if (message.role === "tool") {
const index = pending.shift()
const call = index === undefined ? undefined : items[index]
if (call?.kind === "tool") {
call.ok = message.ok ?? true
call.error = message.error ?? undefined
}
}
}
return items
}
/** A read leaves nothing to refetch; anything else may have changed a flow. */
const isRead = (name: string) => /^(list|get)_/.test(name)
const FLOW_PATH = /^\/flows\/([a-z][a-z0-9_]*)/
/**
* The assistant, floating over whichever screen it was opened on and sharing
* the editor's lane with its panels: when one of those is open the chat takes
* the lower half. A phone gets it as a sheet, like every other panel.
*/
export function ChatPanel() {
const { open, panels } = useChat()
const isMobile = useIsMobile()
const queryClient = useQueryClient()
const status = useQuery(aiStatusQueryOptions())
const pathname = useRouterState({ select: (s) => s.location.pathname })
const flow = FLOW_PATH.exec(pathname)?.[1]
const [items, setItems] = useState<Item[]>([])
const [loaded, setLoaded] = useState(false)
const [draft, setDraft] = useState("")
const [busy, setBusy] = useState(false)
const controller = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open || loaded) return
AiService.readChat()
.then((transcript) => setItems(fromTranscript(transcript)))
.finally(() => setLoaded(true))
}, [open, loaded])
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows, not on every render.
useEffect(() => {
bottom.current?.scrollIntoView({ block: "end" })
}, [items.length, busy])
const send = async () => {
const text = draft.trim()
if (!text || busy) return
setDraft("")
setBusy(true)
setItems((prev) => [...prev, { kind: "user", text }])
const abort = new AbortController()
controller.current = abort
const onEvent = (event: ChatEvent) => {
switch (event.type) {
case "message":
setItems((prev) => [
...prev,
{ kind: "assistant", text: event.content },
])
break
case "tool_call":
setItems((prev) => [
...prev,
{ kind: "tool", name: event.name, args: event.arguments },
])
break
case "tool_result":
setItems((prev) => {
const next = [...prev]
for (let i = next.length - 1; i >= 0; i--) {
const item = next[i]
if (item.kind === "tool" && item.ok === undefined) {
next[i] = { ...item, ok: event.ok, error: event.error }
break
}
}
return next
})
if (!isRead(event.name)) {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
}
break
case "error":
setItems((prev) => [...prev, { kind: "error", text: event.detail }])
break
}
}
await streamTurn({ message: text, flow, signal: abort.signal, onEvent })
controller.current = null
setBusy(false)
}
const clear = () => {
controller.current?.abort()
setItems([])
void AiService.clearChat()
}
if (!status.data?.available) return null
return (
<SidePanel
open={open}
label="Assistant"
testId="chat-panel"
bodyKey="chat"
fixed
stacked={panels > 0 && !isMobile ? "bottom" : false}
onClose={chatStore.close}
header={
<>
<Bot className="size-4 shrink-0 text-muted-foreground" />
{TABS.length > 1 ? (
<Segmented
value={TABS[0][0]}
options={TABS}
label="What to talk about"
onChange={() => {}}
/>
) : (
<span className="text-sm font-medium">Assistant</span>
)}
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{status.data.model}
</span>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={clear}
disabled={items.length === 0}
aria-label="Clear the conversation"
>
<Trash2 />
</Button>
</>
}
footer={
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
void send()
}}
>
<Input
value={draft}
placeholder={
flow
? `Ask about ${flow}`
: "Ask for a flow, a fix, or an explanation"
}
aria-label="Message"
autoComplete="off"
data-testid="chat-input"
onChange={(event) => setDraft(event.target.value)}
/>
{busy ? (
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => controller.current?.abort()}
aria-label="Stop"
>
<Square />
</Button>
) : (
<Button
type="submit"
size="icon"
className="shrink-0"
disabled={!draft.trim()}
aria-label="Send"
>
<Send />
</Button>
)}
</form>
}
>
<div className="flex flex-col gap-2 p-4">
{items.length === 0 && !busy ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Describe a flow to build, a change to make, or ask what a failure
means. Drafts land in the editor for you to publish.
</p>
) : null}
{items.map((item, index) => (
<Message key={index} item={item} />
))}
{busy ? (
<FluksioLoader className="size-4 text-muted-foreground" />
) : null}
<div ref={bottom} />
</div>
</SidePanel>
)
}
function Message({ item }: { item: Item }) {
switch (item.kind) {
case "user":
return (
<div className="max-w-[85%] self-end whitespace-pre-wrap break-words rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground">
{item.text}
</div>
)
case "assistant":
return (
<div className="self-start whitespace-pre-wrap break-words px-1 text-sm">
{item.text}
</div>
)
case "error":
return <div className="px-1 text-xs text-destructive">{item.text}</div>
case "tool":
return (
<details
className={cn(
"self-start px-1 font-mono text-xs",
item.ok === false ? "text-destructive" : "text-muted-foreground",
)}
>
<summary className="cursor-pointer">
{item.ok === undefined ? "…" : item.ok ? "✓" : "✗"} {item.name}
</summary>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2">
{item.error ??
JSON.stringify(item.args ?? {}, null, 1).slice(0, 2000)}
</pre>
</details>
)
}
}
+41
View File
@@ -0,0 +1,41 @@
import { useSyncExternalStore } from "react"
/**
* Whether the assistant is open, and whether another panel is open beside it.
*
* Module state rather than context because the chat is mounted once in the
* sidebar and toggled from there, while the editor's own panels report in
* from a different tree: the two only have to agree on who takes which half
* of the lane.
*/
type State = { open: boolean; panels: number }
let state: State = { open: false, panels: 0 }
const listeners = new Set<() => void>()
function set(next: Partial<State>) {
state = { ...state, ...next }
for (const listener of listeners) listener()
}
export const chatStore = {
open: () => set({ open: true }),
close: () => set({ open: false }),
toggle: () => set({ open: !state.open }),
/** A floating panel is open; returns how to say it closed again. */
registerPanel: () => {
set({ panels: state.panels + 1 })
return () => set({ panels: state.panels - 1 })
},
}
export function useChat(): State {
return useSyncExternalStore(
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => state,
() => state,
)
}
+78
View File
@@ -0,0 +1,78 @@
import { OpenAPI } from "@/client"
import { apiToken } from "@/lib/portal"
export type ChatEvent =
| { type: "start" }
| { type: "message"; content: string }
| { type: "tool_call"; name: string; arguments: Record<string, unknown> }
| { type: "tool_result"; name: string; ok: boolean; error?: string }
| { type: "error"; detail: string }
| { type: "done" }
/**
* One turn, as the server-sent events the API streams while the model works.
*
* A plain fetch rather than the generated client, which has no notion of a
* body that keeps arriving. Every outcome is an event, including a request
* that never got as far as the stream, so the caller has one channel to read.
*/
export async function streamTurn({
message,
flow,
signal,
onEvent,
}: {
message: string
flow?: string
signal: AbortSignal
onEvent: (event: ChatEvent) => void
}): Promise<void> {
let response: Response
try {
response = await fetch(`${OpenAPI.BASE}/api/v1/ai/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiToken()}`,
},
body: JSON.stringify({ message, flow: flow ?? null }),
signal,
})
} catch (error) {
if (!signal.aborted) onEvent({ type: "error", detail: String(error) })
return
}
if (!response.ok || !response.body) {
let detail = `The request failed (${response.status})`
try {
const body = (await response.json()) as { detail?: string }
if (body.detail) detail = body.detail
} catch {
// Not JSON; the status is the message.
}
onEvent({ type: "error", detail })
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let end = buffer.indexOf("\n\n")
while (end !== -1) {
const frame = buffer.slice(0, end).trim()
buffer = buffer.slice(end + 2)
if (frame.startsWith("data:")) {
onEvent(JSON.parse(frame.slice(5)) as ChatEvent)
}
end = buffer.indexOf("\n\n")
}
}
} catch {
// Stopped, or the connection dropped: the turn is over either way.
}
}
+28 -3
View File
@@ -2,7 +2,7 @@ import { Info, X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react"
import { useEffect, useState } from "react"
import { chatStore, useChat } from "@/components/Chat/chatStore"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -180,6 +180,8 @@ export function PanelTitle({
* for that.
*
* Node settings and flow settings share it, so the two read as one surface.
* The assistant shares it too, and the lane: while it is open, a settings
* panel keeps the top half and the chat takes the bottom.
*/
export function SidePanel({
open,
@@ -187,6 +189,8 @@ export function SidePanel({
testId,
bodyKey,
expanded = false,
fixed = false,
stacked,
header,
footer,
children,
@@ -200,12 +204,26 @@ export function SidePanel({
bodyKey: string
/** Take the width a code editor needs, still floating over the canvas. */
expanded?: boolean
/** Pinned to the viewport rather than the canvas: a panel on any screen. */
fixed?: boolean
/**
* Which half of the lane to keep. Left unset, the panel counts itself as
* open and keeps the top whenever the assistant is open beside it.
*/
stacked?: "top" | "bottom" | false
header: ReactNode
footer?: ReactNode
children: ReactNode
onClose: () => void
}) {
const isMobile = useIsMobile()
const chat = useChat()
const shares = stacked === undefined
useEffect(() => {
if (!shares || !open || isMobile) return
return chatStore.registerPanel()
}, [shares, open, isMobile])
const half = shares ? (chat.open ? "top" : false) : stacked
useEffect(() => {
if (!open) return
@@ -284,12 +302,19 @@ export function SidePanel({
data-testid={testId}
data-expanded={expanded || undefined}
className={cn(
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
"pointer-events-auto z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
fixed ? "fixed" : "absolute",
expanded
? // Still a floating surface, given the whole inset: the toolbar
// and the flow-name box translate off screen while it is open.
"inset-4"
: "inset-y-4 right-4 w-[400px]",
: "right-4 w-[400px]",
!expanded &&
(half === "top"
? "top-4 bottom-[calc(50%+0.5rem)]"
: half === "bottom"
? "top-[calc(50%+0.5rem)] bottom-4"
: "inset-y-4"),
)}
>
{contents}
+17 -2
View File
@@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query"
import {
ArrowLeft,
Bell,
BookOpen,
Bot,
FlaskConical,
Home,
KeyRound,
@@ -16,6 +18,8 @@ import {
} from "lucide-react"
import { useState } from "react"
import { aiStatusQueryOptions, ChatPanel } from "@/components/Chat/ChatPanel"
import { chatStore } from "@/components/Chat/chatStore"
import { GlobalSearch } from "@/components/Common/GlobalSearch"
import { Logo } from "@/components/Common/Logo"
import {
@@ -97,10 +101,17 @@ export function AppSidebar() {
onClick: () => setSearchOpen(true),
}
// Only once an operator has set a provider up: with none there is nothing
// to open, and nothing on screen says the feature exists.
const assistant = useQuery(aiStatusQueryOptions())
const tools: Item[] = assistant.data?.available
? [{ icon: Bot, title: "Assistant", onClick: chatStore.toggle }, search]
: [search]
const footerItems: Item[] = portal
? [search, docs, { icon: Settings, title: "Settings", path: "/settings" }]
? [...tools, docs, { icon: Settings, title: "Settings", path: "/settings" }]
: [
search,
...tools,
docs,
{ icon: Settings, title: "Settings", path: "/settings" },
{ icon: LogOut, title: "Log Out", onClick: logout },
@@ -140,6 +151,10 @@ export function AppSidebar() {
{/* Outside the sidebar: on a phone that is a sheet, and a dialog is not
one of its children. */}
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
{/* Mounted once, here, for the same reason: both shells carry the
sidebar, and the chat follows whoever opened it from screen to
screen. */}
<ChatPanel />
</>
)
}
@@ -0,0 +1,236 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { type AIConfigPublic, AiService } from "@/client"
import { InfoTip } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
/** Where the usual providers answer. Anything OpenAI-compatible will do. */
const PROVIDERS = [
["https://api.openai.com/v1", "OpenAI"],
["https://api.anthropic.com/v1", "Anthropic"],
["https://openrouter.ai/api/v1", "OpenRouter"],
["http://localhost:11434/v1", "Ollama"],
] as const
/**
* The assistant's provider: one for the whole instance, set by an operator,
* with the key kept encrypted here and never shown again.
*/
export function Assistant() {
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [apiBase, setApiBase] = useState("")
const [apiKey, setApiKey] = useState("")
const [model, setModel] = useState("")
const [models, setModels] = useState<string[]>([])
const config = useQuery<AIConfigPublic>({
queryKey: ["ai", "config"],
queryFn: () => AiService.readAiConfig(),
})
useEffect(() => {
if (!config.data) return
setApiBase(config.data.api_base)
setModel(config.data.model)
}, [config.data])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ai"] })
const fetchModels = useMutation({
mutationFn: () =>
AiService.listAiModels({
requestBody: {
api_base: apiBase.trim(),
api_key: apiKey.trim() || null,
},
}),
onSuccess: (list) => {
setModels(list)
if (list.length && !list.includes(model)) setModel(list[0])
},
onError: handleError.bind(showErrorToast),
})
const save = useMutation({
mutationFn: () =>
AiService.updateAiConfig({
requestBody: {
api_base: apiBase.trim(),
api_key: apiKey.trim() || null,
model: model.trim(),
},
}),
onSuccess: () => {
setApiKey("")
showSuccessToast("Assistant ready")
invalidate()
},
onError: handleError.bind(showErrorToast),
})
const remove = useMutation({
mutationFn: () => AiService.deleteAiConfig(),
onSuccess: () => {
setApiKey("")
setModels([])
showSuccessToast("Assistant turned off")
invalidate()
},
onError: handleError.bind(showErrorToast),
})
if (!config.data) return null
const keySet = config.data.api_key_set
const preset = PROVIDERS.find(([url]) => url === apiBase)?.[0] ?? "custom"
return (
<Card>
<CardHeader>
<CardTitle>Assistant</CardTitle>
<CardDescription>
Builds and edits flows from a chat, through the same API as agents.
Needs a model provider; everyone on this instance shares it.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="ai-provider">Provider</Label>
<Select
value={preset}
onValueChange={(value) => {
if (value !== "custom") setApiBase(value)
setModels([])
}}
>
<SelectTrigger id="ai-provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map(([url, name]) => (
<SelectItem key={url} value={url}>
{name}
</SelectItem>
))}
<SelectItem value="custom">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1">
<Label htmlFor="ai-base">URL</Label>
<InfoTip label="URL">
An OpenAI-compatible base, ending in /v1. A server on your own
network works too.
</InfoTip>
</div>
<Input
id="ai-base"
value={apiBase}
placeholder="https://…/v1"
autoComplete="off"
onChange={(event) => setApiBase(event.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1">
<Label htmlFor="ai-key">API key</Label>
<InfoTip label="API key">
Stored encrypted on this instance and never shown again. Leave it
empty to keep the one saved.
</InfoTip>
</div>
<PasswordInput
id="ai-key"
value={apiKey}
placeholder={keySet ? (config.data.api_key_masked ?? "saved") : ""}
autoComplete="off"
onChange={(event) => setApiKey(event.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ai-model">Model</Label>
<div className="flex gap-2">
{models.length ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id="ai-model" className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="ai-model"
value={model}
placeholder="Fetch the list, or type a name"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setModel(event.target.value)}
/>
)}
<LoadingButton
variant="outline"
className="shrink-0 rounded-full"
loading={fetchModels.isPending}
disabled={!apiBase.trim() || (!apiKey.trim() && !keySet)}
onClick={() => fetchModels.mutate()}
>
Fetch models
</LoadingButton>
</div>
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton
variant="brand"
className="rounded-full"
loading={save.isPending}
disabled={
!apiBase.trim() || !model.trim() || (!apiKey.trim() && !keySet)
}
onClick={() => save.mutate()}
data-testid="save-assistant"
>
Save
</LoadingButton>
{keySet ? (
<Button
variant="outline"
className="rounded-full"
onClick={() => remove.mutate()}
>
Turn off
</Button>
) : null}
</div>
</CardContent>
</Card>
)
}
export default Assistant
+7
View File
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"
import { motion } from "motion/react"
import Appearance from "@/components/UserSettings/Appearance"
import Assistant from "@/components/UserSettings/Assistant"
import RemoteAccess from "@/components/UserSettings/RemoteAccess"
import UserInformation from "@/components/UserSettings/UserInformation"
import useAuth from "@/hooks/useAuth"
@@ -57,6 +58,12 @@ function UserSettings() {
<RemoteAccess />
</motion.div>
)}
{/* One provider for everyone here, so the same rule applies. */}
{currentUser.is_superuser && (
<motion.div variants={slideUp}>
<Assistant />
</motion.div>
)}
</motion.div>
</div>
)