From cc4091166ec4e537d26cea75c57bead9d9dcd06c Mon Sep 17 00:00:00 2001 From: stroblme Date: Wed, 16 Sep 2026 23:28:23 +0200 Subject: [PATCH] Add the assistant: a chat that drives the MCP tools in-process 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 --- backend/fluksio/ai/__init__.py | 7 + backend/fluksio/ai/agent.py | 172 ++++++++++ backend/fluksio/ai/client.py | 76 +++++ backend/fluksio/ai/config.py | 98 ++++++ backend/fluksio/api/main.py | 4 + backend/fluksio/api/routes/ai.py | 250 ++++++++++++++ backend/fluksio/core/config.py | 8 + backend/fluksio/flow/secrets.py | 10 +- backend/fluksio/main.py | 10 +- backend/fluksio/mcp/http.py | 21 +- backend/fluksio/mcp/server.py | 54 ++- backend/tests/test_ai.py | 138 ++++++++ docs/code/agents.md | 19 ++ docs/interface/index.md | 2 +- docs/interface/operations.md | 7 + frontend/src/client/schemas.gen.ts | 209 ++++++++++++ frontend/src/client/sdk.gen.ts | 128 +++++++- frontend/src/client/types.gen.ts | 68 ++++ frontend/src/components/Chat/ChatPanel.tsx | 307 ++++++++++++++++++ frontend/src/components/Chat/chatStore.ts | 41 +++ frontend/src/components/Chat/stream.ts | 78 +++++ frontend/src/components/Flow/SidePanel.tsx | 31 +- .../src/components/Sidebar/AppSidebar.tsx | 19 +- .../src/components/UserSettings/Assistant.tsx | 236 ++++++++++++++ frontend/src/routes/_layout/settings.tsx | 7 + 25 files changed, 1957 insertions(+), 43 deletions(-) create mode 100644 backend/fluksio/ai/__init__.py create mode 100644 backend/fluksio/ai/agent.py create mode 100644 backend/fluksio/ai/client.py create mode 100644 backend/fluksio/ai/config.py create mode 100644 backend/fluksio/api/routes/ai.py create mode 100644 backend/tests/test_ai.py create mode 100644 frontend/src/components/Chat/ChatPanel.tsx create mode 100644 frontend/src/components/Chat/chatStore.ts create mode 100644 frontend/src/components/Chat/stream.ts create mode 100644 frontend/src/components/UserSettings/Assistant.tsx diff --git a/backend/fluksio/ai/__init__.py b/backend/fluksio/ai/__init__.py new file mode 100644 index 0000000..23d1fcc --- /dev/null +++ b/backend/fluksio/ai/__init__.py @@ -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. +""" diff --git a/backend/fluksio/ai/agent.py b/backend/fluksio/ai/agent.py new file mode 100644 index 0000000..123b9b3 --- /dev/null +++ b/backend/fluksio/ai/agent.py @@ -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) diff --git a/backend/fluksio/ai/client.py b/backend/fluksio/ai/client.py new file mode 100644 index 0000000..fd19e75 --- /dev/null +++ b/backend/fluksio/ai/client.py @@ -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) diff --git a/backend/fluksio/ai/config.py b/backend/fluksio/ai/config.py new file mode 100644 index 0000000..caaaddc --- /dev/null +++ b/backend/fluksio/ai/config.py @@ -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) diff --git a/backend/fluksio/api/main.py b/backend/fluksio/api/main.py index a4e1bdf..0d4f8b2 100644 --- a/backend/fluksio/api/main.py +++ b/backend/fluksio/api/main.py @@ -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) diff --git a/backend/fluksio/api/routes/ai.py b/backend/fluksio/api/routes/ai.py new file mode 100644 index 0000000..89d594f --- /dev/null +++ b/backend/fluksio/api/routes/ai.py @@ -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"}, + ) diff --git a/backend/fluksio/core/config.py b/backend/fluksio/core/config.py index cdadf47..3dd2e6b 100644 --- a/backend/fluksio/core/config.py +++ b/backend/fluksio/core/config.py @@ -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. diff --git a/backend/fluksio/flow/secrets.py b/backend/fluksio/flow/secrets.py index adfa5fb..0774f85 100644 --- a/backend/fluksio/flow/secrets.py +++ b/backend/fluksio/flow/secrets.py @@ -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(): diff --git a/backend/fluksio/main.py b/backend/fluksio/main.py index 9fcb9c1..fef3049 100644 --- a/backend/fluksio/main.py +++ b/backend/fluksio/main.py @@ -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 diff --git a/backend/fluksio/mcp/http.py b/backend/fluksio/mcp/http.py index 6f77fc0..3e1cc22 100644 --- a/backend/fluksio/mcp/http.py +++ b/backend/fluksio/mcp/http.py @@ -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) diff --git a/backend/fluksio/mcp/server.py b/backend/fluksio/mcp/server.py index e17f074..540bde0 100644 --- a/backend/fluksio/mcp/server.py +++ b/backend/fluksio/mcp/server.py @@ -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 diff --git a/backend/tests/test_ai.py b/backend/tests/test_ai.py new file mode 100644 index 0000000..430df41 --- /dev/null +++ b/backend/tests/test_ai.py @@ -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() == [] diff --git a/docs/code/agents.md b/docs/code/agents.md index 31d9d45..0a10d6a 100644 --- a/docs/code/agents.md +++ b/docs/code/agents.md @@ -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 diff --git a/docs/interface/index.md b/docs/interface/index.md index e2680c1..b125252 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -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 diff --git a/docs/interface/operations.md b/docs/interface/operations.md index e6fee0a..b08a1c2 100644 --- a/docs/interface/operations.md +++ b/docs/interface/operations.md @@ -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). diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index ed7548c..bfcec84 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 4428496..fb20335 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,133 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; + +export class AiService { + /** + * Read Ai Config + * @returns AIConfigPublic Successful Response + * @throws ApiError + */ + public static readAiConfig(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/ai/config' + }); + } + + /** + * Update Ai Config + * @param data The data for the request. + * @param data.requestBody + * @returns AIConfigPublic Successful Response + * @throws ApiError + */ + public static updateAiConfig(data: AiUpdateAiConfigData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/ai/config', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Ai Config + * @returns unknown Successful Response + * @throws ApiError + */ + public static deleteAiConfig(): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/ai/config' + }); + } + + /** + * List Ai Models + * What the provider offers, tried with the key given or the one stored. + * @param data The data for the request. + * @param data.requestBody + * @returns string Successful Response + * @throws ApiError + */ + public static listAiModels(data: AiListAiModelsData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/ai/config/models', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Ai Status + * Whether there is an assistant to show. Readable by anyone signed in. + * @returns AIStatus Successful Response + * @throws ApiError + */ + public static readAiStatus(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/ai/status' + }); + } + + /** + * Read Chat + * The transcript, with each tool's answer reduced to whether it worked. + * @returns ChatItem Successful Response + * @throws ApiError + */ + public static readChat(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/ai/chat' + }); + } + + /** + * Chat + * 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. + * @param data The data for the request. + * @param data.requestBody + * @returns unknown Successful Response + * @throws ApiError + */ + public static chat(data: AiChatData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/ai/chat', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Clear Chat + * @returns ChatItem Successful Response + * @throws ApiError + */ + public static clearChat(): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/ai/chat' + }); + } +} export class AlertsService { /** diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index bf54e3e..744a3e3 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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); + +export type AiChatData = { + requestBody: TurnBody; +}; + +export type AiChatResponse = (unknown); + +export type AiClearChatResponse = (Array); + export type AlertsReadAlertsConfigResponse = (AlertsConfig); export type AlertsSaveAlertsConfigData = { diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx new file mode 100644 index 0000000..11d5d1e --- /dev/null +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -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 + 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 | undefined + try { + args = JSON.parse(fn.arguments ?? "{}") as Record + } 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([]) + const [loaded, setLoaded] = useState(false) + const [draft, setDraft] = useState("") + const [busy, setBusy] = useState(false) + const controller = useRef(null) + const bottom = useRef(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 ( + 0 && !isMobile ? "bottom" : false} + onClose={chatStore.close} + header={ + <> + + {TABS.length > 1 ? ( + {}} + /> + ) : ( + Assistant + )} + + {status.data.model} + + + + } + footer={ +
{ + event.preventDefault() + void send() + }} + > + setDraft(event.target.value)} + /> + {busy ? ( + + ) : ( + + )} +
+ } + > +
+ {items.length === 0 && !busy ? ( +

+ Describe a flow to build, a change to make, or ask what a failure + means. Drafts land in the editor for you to publish. +

+ ) : null} + {items.map((item, index) => ( + + ))} + {busy ? ( + + ) : null} +
+
+ + ) +} + +function Message({ item }: { item: Item }) { + switch (item.kind) { + case "user": + return ( +
+ {item.text} +
+ ) + case "assistant": + return ( +
+ {item.text} +
+ ) + case "error": + return
{item.text}
+ case "tool": + return ( +
+ + {item.ok === undefined ? "…" : item.ok ? "✓" : "✗"} {item.name} + +
+            {item.error ??
+              JSON.stringify(item.args ?? {}, null, 1).slice(0, 2000)}
+          
+
+ ) + } +} diff --git a/frontend/src/components/Chat/chatStore.ts b/frontend/src/components/Chat/chatStore.ts new file mode 100644 index 0000000..f4ca265 --- /dev/null +++ b/frontend/src/components/Chat/chatStore.ts @@ -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, ...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, + ) +} diff --git a/frontend/src/components/Chat/stream.ts b/frontend/src/components/Chat/stream.ts new file mode 100644 index 0000000..bc8b276 --- /dev/null +++ b/frontend/src/components/Chat/stream.ts @@ -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 } + | { 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 { + 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. + } +} diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx index 8f5b2eb..9ff65b0 100644 --- a/frontend/src/components/Flow/SidePanel.tsx +++ b/frontend/src/components/Flow/SidePanel.tsx @@ -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} diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index db6534b..a85a79d 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -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. */} + {/* Mounted once, here, for the same reason: both shells carry the + sidebar, and the chat follows whoever opened it from screen to + screen. */} + ) } diff --git a/frontend/src/components/UserSettings/Assistant.tsx b/frontend/src/components/UserSettings/Assistant.tsx new file mode 100644 index 0000000..d0a5f96 --- /dev/null +++ b/frontend/src/components/UserSettings/Assistant.tsx @@ -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([]) + + const config = useQuery({ + 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 ( + + + Assistant + + Builds and edits flows from a chat, through the same API as agents. + Needs a model provider; everyone on this instance shares it. + + + +
+ + +
+
+
+ + + An OpenAI-compatible base, ending in /v1. A server on your own + network works too. + +
+ setApiBase(event.target.value)} + /> +
+
+
+ + + Stored encrypted on this instance and never shown again. Leave it + empty to keep the one saved. + +
+ setApiKey(event.target.value)} + /> +
+
+ +
+ {models.length ? ( + + ) : ( + setModel(event.target.value)} + /> + )} + fetchModels.mutate()} + > + Fetch models + +
+
+
+ save.mutate()} + data-testid="save-assistant" + > + Save + + {keySet ? ( + + ) : null} +
+
+
+ ) +} + +export default Assistant diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx index 788fa41..caace7b 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout/settings.tsx @@ -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() { )} + {/* One provider for everyone here, so the same rule applies. */} + {currentUser.is_superuser && ( + + + + )}
)