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.
99 lines
3.0 KiB
Python
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)
|