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