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