Files
app/backend/fluksio/ai/config.py
T
stroblme 7240b7b823 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.
2026-09-16 23:28:23 +02:00

99 lines
3.0 KiB
Python

"""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)