Merge pull request #26 from stefanljosbra/main
Upload button on Assets and multiple LLM endpoints in Settings
This commit is contained in:
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -99,6 +101,10 @@ class Settings(BaseSettings):
|
||||
llm_base_url: str = "http://127.0.0.1:11434/v1"
|
||||
llm_model: str = "llama3.2"
|
||||
llm_api_key: str | None = None
|
||||
# Named OpenAI-compatible endpoints. `llm_*` above always mirror the active
|
||||
# profile so LLMClient and env overrides keep working.
|
||||
llm_profiles: list[dict[str, Any]] = Field(default_factory=list)
|
||||
llm_active_id: str | None = None
|
||||
|
||||
comfyui_base_url: str = "http://127.0.0.1:8188"
|
||||
# Comfy is HTTP-only (upload / prompt / history / view). No local input/output dirs.
|
||||
@@ -122,8 +128,144 @@ class Settings(BaseSettings):
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def ensure_llm_profiles(self) -> bool:
|
||||
"""Guarantee at least one profile and that llm_* match the active one.
|
||||
|
||||
Returns True when the in-memory list was created or repaired (caller
|
||||
may persist). Existing configs with only llm_base_url/model/key are
|
||||
migrated into a single named profile.
|
||||
"""
|
||||
migrated = False
|
||||
raw_list = self.llm_profiles if isinstance(self.llm_profiles, list) else []
|
||||
if not raw_list:
|
||||
pid = str(uuid.uuid4())
|
||||
self.llm_profiles = [
|
||||
{
|
||||
"id": pid,
|
||||
"name": self.llm_model or "Default",
|
||||
"base_url": self.llm_base_url,
|
||||
"model": self.llm_model,
|
||||
"api_key": self.llm_api_key,
|
||||
}
|
||||
]
|
||||
self.llm_active_id = pid
|
||||
migrated = True
|
||||
else:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in raw_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
pid = str(item.get("id") or uuid.uuid4())
|
||||
model = str(item.get("model") or self.llm_model or "llama3.2").strip()
|
||||
name = str(item.get("name") or model or "LLM").strip()
|
||||
base_url = str(item.get("base_url") or self.llm_base_url).strip()
|
||||
key = item.get("api_key")
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
key = None
|
||||
normalized.append(
|
||||
{
|
||||
"id": pid,
|
||||
"name": name or model or "LLM",
|
||||
"base_url": base_url or self.llm_base_url,
|
||||
"model": model or "llama3.2",
|
||||
"api_key": key,
|
||||
}
|
||||
)
|
||||
if not normalized:
|
||||
self.llm_profiles = []
|
||||
return self.ensure_llm_profiles()
|
||||
self.llm_profiles = normalized
|
||||
ids = {p["id"] for p in normalized}
|
||||
if self.llm_active_id not in ids:
|
||||
self.llm_active_id = normalized[0]["id"]
|
||||
migrated = True
|
||||
self.apply_active_llm()
|
||||
return migrated
|
||||
|
||||
def apply_active_llm(self) -> None:
|
||||
"""Copy the active profile onto llm_base_url / llm_model / llm_api_key."""
|
||||
profiles = self.llm_profiles if isinstance(self.llm_profiles, list) else []
|
||||
profile = next((p for p in profiles if p.get("id") == self.llm_active_id), None)
|
||||
if profile is None and profiles:
|
||||
profile = profiles[0]
|
||||
self.llm_active_id = str(profile.get("id") or "")
|
||||
if profile is None:
|
||||
return
|
||||
if profile.get("base_url"):
|
||||
self.llm_base_url = str(profile["base_url"])
|
||||
if profile.get("model"):
|
||||
self.llm_model = str(profile["model"])
|
||||
self.llm_api_key = profile.get("api_key") if isinstance(profile.get("api_key"), str) else None
|
||||
|
||||
def replace_llm_profiles(self, incoming: list[dict[str, Any]]) -> None:
|
||||
"""Replace the profile list, preserving secrets unless a new key is sent."""
|
||||
existing = {
|
||||
str(p["id"]): p
|
||||
for p in (self.llm_profiles or [])
|
||||
if isinstance(p, dict) and p.get("id")
|
||||
}
|
||||
new_list: list[dict[str, Any]] = []
|
||||
for raw in incoming:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
pid = str(raw["id"]) if raw.get("id") else str(uuid.uuid4())
|
||||
old = existing.get(pid, {})
|
||||
model = str(raw.get("model") or old.get("model") or self.llm_model or "llama3.2").strip()
|
||||
name = str(raw.get("name") or old.get("name") or model or "LLM").strip()
|
||||
base_url = str(raw.get("base_url") or old.get("base_url") or self.llm_base_url or "").strip()
|
||||
api_key = old.get("api_key") if isinstance(old.get("api_key"), str) else None
|
||||
incoming_key = raw.get("api_key")
|
||||
if isinstance(incoming_key, str) and incoming_key.strip():
|
||||
api_key = incoming_key.strip()
|
||||
new_list.append(
|
||||
{
|
||||
"id": pid,
|
||||
"name": name or model or "LLM",
|
||||
"base_url": base_url or self.llm_base_url,
|
||||
"model": model or "llama3.2",
|
||||
"api_key": api_key,
|
||||
}
|
||||
)
|
||||
if not new_list:
|
||||
raise ValueError("At least one LLM is required")
|
||||
self.llm_profiles = new_list
|
||||
ids = {p["id"] for p in new_list}
|
||||
if self.llm_active_id not in ids:
|
||||
self.llm_active_id = new_list[0]["id"]
|
||||
self.apply_active_llm()
|
||||
|
||||
def sync_legacy_llm_into_active(self) -> None:
|
||||
"""Write current llm_* fields onto the active profile (legacy PATCH)."""
|
||||
base_url = self.llm_base_url
|
||||
model = self.llm_model
|
||||
api_key = self.llm_api_key
|
||||
self.ensure_llm_profiles()
|
||||
for profile in self.llm_profiles:
|
||||
if profile.get("id") == self.llm_active_id:
|
||||
profile["base_url"] = base_url
|
||||
profile["model"] = model
|
||||
profile["api_key"] = api_key
|
||||
if not str(profile.get("name") or "").strip():
|
||||
profile["name"] = model
|
||||
break
|
||||
self.apply_active_llm()
|
||||
|
||||
def _public_llm_profiles(self) -> list[dict[str, Any]]:
|
||||
self.ensure_llm_profiles()
|
||||
return [
|
||||
{
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"base_url": p["base_url"],
|
||||
"model": p["model"],
|
||||
"api_key": bool(p.get("api_key")),
|
||||
}
|
||||
for p in self.llm_profiles
|
||||
]
|
||||
|
||||
def to_public_dict(self) -> dict[str, Any]:
|
||||
"""Return settings that are safe to expose to the frontend."""
|
||||
profiles = self._public_llm_profiles()
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
@@ -133,6 +275,8 @@ class Settings(BaseSettings):
|
||||
"llm_base_url": self.llm_base_url,
|
||||
"llm_model": self.llm_model,
|
||||
"llm_api_key": bool(self.llm_api_key),
|
||||
"llm_profiles": profiles,
|
||||
"llm_active_id": self.llm_active_id,
|
||||
"comfyui_base_url": self.comfyui_base_url,
|
||||
"queue_concurrency": self.queue_concurrency,
|
||||
"queue_poll_interval_sec": self.queue_poll_interval_sec,
|
||||
@@ -183,6 +327,7 @@ class Settings(BaseSettings):
|
||||
self._apply_storage_paths(data_dir, assets_dir)
|
||||
self.dry_run = bool(data.get("dry_run", False))
|
||||
self.ensure_storage_dirs()
|
||||
profiles_migrated = self.ensure_llm_profiles()
|
||||
|
||||
# Auto-heal poisoned config (temp paths / quoted dirs / legacy comfy dirs) back to disk
|
||||
needs_rewrite = is_ephemeral_path(normalize_path(data.get("data_dir"))) or any(
|
||||
@@ -211,6 +356,8 @@ class Settings(BaseSettings):
|
||||
"llm_base_url": self.llm_base_url,
|
||||
"llm_model": self.llm_model,
|
||||
"llm_api_key": self.llm_api_key,
|
||||
"llm_profiles": self.llm_profiles,
|
||||
"llm_active_id": self.llm_active_id,
|
||||
"comfyui_base_url": self.comfyui_base_url,
|
||||
"queue_concurrency": self.queue_concurrency,
|
||||
"queue_poll_interval_sec": self.queue_poll_interval_sec,
|
||||
|
||||
@@ -2,18 +2,30 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from calliope.config import normalize_path, settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_LEGACY_LLM_KEYS = {"llm_base_url", "llm_model", "llm_api_key"}
|
||||
|
||||
|
||||
class LlmProfileIn(BaseModel):
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
base_url: str | None = None
|
||||
model: str | None = None
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
llm_base_url: str | None = None
|
||||
llm_model: str | None = None
|
||||
llm_api_key: str | None = None
|
||||
llm_profiles: list[LlmProfileIn] | None = None
|
||||
llm_active_id: str | None = None
|
||||
comfyui_base_url: str | None = None
|
||||
data_dir: str | None = None
|
||||
assets_dir: str | None = None
|
||||
@@ -34,6 +46,9 @@ async def get_settings() -> dict[str, Any]:
|
||||
@router.post("")
|
||||
async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
profiles_in = data.pop("llm_profiles", None)
|
||||
active_in = data.pop("llm_active_id", None)
|
||||
legacy_llm = {k: data.pop(k) for k in list(data) if k in _LEGACY_LLM_KEYS}
|
||||
|
||||
for key, value in data.items():
|
||||
if key in {"data_dir", "assets_dir"}:
|
||||
@@ -46,5 +61,24 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
|
||||
continue
|
||||
setattr(settings, key, value)
|
||||
|
||||
if profiles_in is not None:
|
||||
try:
|
||||
settings.replace_llm_profiles(profiles_in)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
if active_in is not None:
|
||||
settings.ensure_llm_profiles()
|
||||
ids = {p["id"] for p in settings.llm_profiles}
|
||||
if active_in not in ids:
|
||||
raise HTTPException(status_code=400, detail="Unknown LLM profile")
|
||||
settings.llm_active_id = active_in
|
||||
settings.apply_active_llm()
|
||||
|
||||
if legacy_llm:
|
||||
for key, value in legacy_llm.items():
|
||||
setattr(settings, key, value)
|
||||
settings.sync_legacy_llm_into_active()
|
||||
|
||||
settings.save_config_file()
|
||||
return settings.to_public_dict()
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Multi-LLM profile settings: store several endpoints and pick the active one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from calliope.config import settings
|
||||
|
||||
|
||||
def _restore(prev: dict) -> None:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def _snapshot() -> dict:
|
||||
return {
|
||||
"llm_profiles": [dict(p) for p in (settings.llm_profiles or [])],
|
||||
"llm_active_id": settings.llm_active_id,
|
||||
"llm_base_url": settings.llm_base_url,
|
||||
"llm_model": settings.llm_model,
|
||||
"llm_api_key": settings.llm_api_key,
|
||||
}
|
||||
|
||||
|
||||
def test_get_settings_includes_llm_profiles(client):
|
||||
r = client.get("/api/settings")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["llm_profiles"]
|
||||
assert body["llm_active_id"]
|
||||
assert body["llm_model"]
|
||||
for profile in body["llm_profiles"]:
|
||||
assert set(profile) >= {"id", "name", "base_url", "model", "api_key"}
|
||||
assert isinstance(profile["api_key"], bool)
|
||||
|
||||
|
||||
def test_llm_profiles_roundtrip_and_switch(client):
|
||||
prev = _snapshot()
|
||||
try:
|
||||
a_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
b_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={
|
||||
"llm_profiles": [
|
||||
{
|
||||
"id": a_id,
|
||||
"name": "Local",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
"model": "llama3.2",
|
||||
"api_key": "secret-a",
|
||||
},
|
||||
{
|
||||
"id": b_id,
|
||||
"name": "Cloud",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o",
|
||||
"api_key": "secret-b",
|
||||
},
|
||||
],
|
||||
"llm_active_id": b_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["llm_active_id"] == b_id
|
||||
assert body["llm_model"] == "gpt-4o"
|
||||
assert body["llm_base_url"] == "https://api.openai.com/v1"
|
||||
assert body["llm_api_key"] is True
|
||||
assert all(isinstance(p["api_key"], bool) for p in body["llm_profiles"])
|
||||
assert settings.llm_api_key == "secret-b"
|
||||
|
||||
r = client.post("/api/settings", json={"llm_active_id": a_id})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["llm_active_id"] == a_id
|
||||
assert body["llm_model"] == "llama3.2"
|
||||
assert settings.llm_api_key == "secret-a"
|
||||
|
||||
# Omitting api_key on save must keep the stored secret.
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={
|
||||
"llm_profiles": [
|
||||
{
|
||||
"id": a_id,
|
||||
"name": "Local",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
"model": "llama3.2",
|
||||
},
|
||||
{
|
||||
"id": b_id,
|
||||
"name": "Cloud",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o",
|
||||
},
|
||||
],
|
||||
"llm_active_id": b_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert settings.llm_api_key == "secret-b"
|
||||
finally:
|
||||
_restore(prev)
|
||||
|
||||
|
||||
def test_llm_profiles_reject_empty_and_unknown_active(client):
|
||||
prev = _snapshot()
|
||||
try:
|
||||
r = client.post("/api/settings", json={"llm_profiles": []})
|
||||
assert r.status_code == 400
|
||||
|
||||
r = client.post("/api/settings", json={"llm_active_id": "does-not-exist"})
|
||||
assert r.status_code == 400
|
||||
finally:
|
||||
_restore(prev)
|
||||
|
||||
|
||||
def test_legacy_llm_fields_update_active_profile(client):
|
||||
prev = _snapshot()
|
||||
try:
|
||||
settings.ensure_llm_profiles()
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={
|
||||
"llm_base_url": "http://127.0.0.1:1234/v1",
|
||||
"llm_model": "legacy-model",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["llm_model"] == "legacy-model"
|
||||
active = next(p for p in body["llm_profiles"] if p["id"] == body["llm_active_id"])
|
||||
assert active["model"] == "legacy-model"
|
||||
assert active["base_url"] == "http://127.0.0.1:1234/v1"
|
||||
finally:
|
||||
_restore(prev)
|
||||
@@ -78,3 +78,55 @@ def test_upload_extension_map():
|
||||
assert _kind_for_ext(".JPG") == "image" # case-insensitive
|
||||
assert _kind_for_ext(".exe") is None
|
||||
assert _kind_for_ext("") is None
|
||||
|
||||
|
||||
def test_upload_assigned_to_character_sheet(client):
|
||||
proj = client.post("/api/projects", json={"title": "Reel"}).json()
|
||||
char = client.post(
|
||||
f"/api/projects/{proj['id']}/characters",
|
||||
json={"name": "Hero"},
|
||||
).json()
|
||||
up = client.post(
|
||||
"/api/playground/uploads",
|
||||
files={"file": ("hero.png", PNG_BYTES, "image/png")},
|
||||
)
|
||||
assert up.status_code == 200
|
||||
path = up.json()["path"]
|
||||
|
||||
patched = client.patch(
|
||||
f"/api/projects/{proj['id']}/characters/{char['id']}",
|
||||
json={"sheet_path": path},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["sheet_path"] == path
|
||||
|
||||
loc = client.post(
|
||||
f"/api/projects/{proj['id']}/locations",
|
||||
json={"name": "Dock"},
|
||||
).json()
|
||||
loc_up = client.post(
|
||||
"/api/playground/uploads",
|
||||
files={"file": ("dock.png", PNG_BYTES, "image/png")},
|
||||
).json()
|
||||
loc_patched = client.patch(
|
||||
f"/api/projects/{proj['id']}/locations/{loc['id']}",
|
||||
json={"reference_image_path": loc_up["path"]},
|
||||
)
|
||||
assert loc_patched.status_code == 200
|
||||
assert loc_patched.json()["reference_image_path"] == loc_up["path"]
|
||||
|
||||
item = client.post(
|
||||
f"/api/projects/{proj['id']}/items",
|
||||
json={"name": "Key"},
|
||||
).json()
|
||||
item_up = client.post(
|
||||
"/api/playground/uploads",
|
||||
files={"file": ("key.png", PNG_BYTES, "image/png")},
|
||||
).json()
|
||||
item_patched = client.patch(
|
||||
f"/api/projects/{proj['id']}/items/{item['id']}",
|
||||
json={"reference_image_path": item_up["path"]},
|
||||
)
|
||||
assert item_patched.status_code == 200
|
||||
assert item_patched.json()["reference_image_path"] == item_up["path"]
|
||||
|
||||
|
||||
@@ -74,6 +74,14 @@ export interface StoryData {
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
export interface LlmProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -83,6 +91,8 @@ export interface Settings {
|
||||
llm_base_url: string;
|
||||
llm_model: string;
|
||||
llm_api_key: boolean;
|
||||
llm_profiles: LlmProfile[];
|
||||
llm_active_id: string | null;
|
||||
comfyui_base_url: string;
|
||||
queue_concurrency: number;
|
||||
queue_poll_interval_sec: number;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {
|
||||
assetUrl,
|
||||
jobsApi,
|
||||
playgroundApi,
|
||||
projects,
|
||||
workflows,
|
||||
type Character,
|
||||
@@ -72,6 +73,9 @@
|
||||
type DeleteTarget = { kind: 'character' | 'location' | 'item'; id: number; name: string };
|
||||
let deleteTarget = $state<DeleteTarget | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
let fileInput: HTMLInputElement | null = $state(null);
|
||||
let uploadTarget = $state<DeleteTarget | null>(null);
|
||||
let uploadingKey = $state<string | null>(null);
|
||||
|
||||
const assetsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
@@ -502,14 +506,73 @@
|
||||
function goToStory() {
|
||||
goto('?stage=story', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function uploadKey(kind: DeleteTarget['kind'], id: number) {
|
||||
if (kind === 'character') return charKey(id);
|
||||
if (kind === 'location') return locKey(id);
|
||||
return itemKey(id);
|
||||
}
|
||||
|
||||
function openOwnImage(target: DeleteTarget) {
|
||||
uploadTarget = target;
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function onOwnImageChosen(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
const target = uploadTarget;
|
||||
uploadTarget = null;
|
||||
if (!file || !target) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please choose an image file (png, jpg, webp, gif)');
|
||||
return;
|
||||
}
|
||||
const key = uploadKey(target.kind, target.id);
|
||||
uploadingKey = key;
|
||||
try {
|
||||
const uploaded = await playgroundApi.upload(file);
|
||||
if (uploaded.kind !== 'image') {
|
||||
toast.error('That file is not an image');
|
||||
return;
|
||||
}
|
||||
if (target.kind === 'character') {
|
||||
await projects.updateCharacter(projectId, target.id, { sheet_path: uploaded.path });
|
||||
} else if (target.kind === 'location') {
|
||||
await projects.updateLocation(projectId, target.id, {
|
||||
reference_image_path: uploaded.path,
|
||||
});
|
||||
} else {
|
||||
await projects.updateItem(projectId, target.id, { reference_image_path: uploaded.path });
|
||||
}
|
||||
await client.invalidateQueries({ queryKey: ['assets', projectId] });
|
||||
await client.invalidateQueries({ queryKey: ['project', projectId] });
|
||||
await client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(`Image added for ${target.name}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Could not add image');
|
||||
} finally {
|
||||
uploadingKey = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp"
|
||||
onchange={onOwnImageChosen}
|
||||
/>
|
||||
|
||||
<header class="stage-header">
|
||||
<div>
|
||||
<h2>2. Characters, Environments & Items</h2>
|
||||
<p class="lead">
|
||||
Pick a workflow below. Sheet/environment/item prompts use the built-in template by
|
||||
default — expand Edit prompt only if you need to tweak text.
|
||||
Pick a workflow below, or upload your own image on any card. Sheet/environment/item
|
||||
prompts use the built-in template by default — expand Edit prompt only if you need to
|
||||
tweak text.
|
||||
</p>
|
||||
</div>
|
||||
<div class="stage-actions">
|
||||
@@ -730,6 +793,18 @@
|
||||
>View {char.name} sheet full size</span
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="Upload your own image"
|
||||
disabled={jstate === 'generating' || uploadingKey === charKey(char.id)}
|
||||
onclick={() =>
|
||||
openOwnImage({ kind: 'character', id: char.id, name: char.name })}
|
||||
>
|
||||
<Icon name="upload" size={14} /><span class="sr-only"
|
||||
>Upload image for {char.name}</span
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -813,6 +888,17 @@
|
||||
? 'Regenerate sheet'
|
||||
: 'Generate sheet'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={jstate === 'generating' || uploadingKey === charKey(char.id)}
|
||||
loading={uploadingKey === charKey(char.id)}
|
||||
onclick={() =>
|
||||
openOwnImage({ kind: 'character', id: char.id, name: char.name })}
|
||||
>
|
||||
<Icon name="upload" size={13} />
|
||||
{char.sheet_path ? 'Replace image' : 'Upload image'}
|
||||
</Button>
|
||||
{#if jstate === 'failed' && job}
|
||||
<Button
|
||||
variant="danger"
|
||||
@@ -910,6 +996,18 @@
|
||||
>View {loc.name} full size</span
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="Upload your own image"
|
||||
disabled={jstate === 'generating' || uploadingKey === locKey(loc.id)}
|
||||
onclick={() =>
|
||||
openOwnImage({ kind: 'location', id: loc.id, name: loc.name })}
|
||||
>
|
||||
<Icon name="upload" size={14} /><span class="sr-only"
|
||||
>Upload image for {loc.name}</span
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -979,6 +1077,17 @@
|
||||
? 'Regenerate image'
|
||||
: 'Generate image'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={jstate === 'generating' || uploadingKey === locKey(loc.id)}
|
||||
loading={uploadingKey === locKey(loc.id)}
|
||||
onclick={() =>
|
||||
openOwnImage({ kind: 'location', id: loc.id, name: loc.name })}
|
||||
>
|
||||
<Icon name="upload" size={13} />
|
||||
{loc.reference_image_path ? 'Replace image' : 'Upload image'}
|
||||
</Button>
|
||||
{#if jstate === 'failed' && job}
|
||||
<Button
|
||||
variant="danger"
|
||||
@@ -1076,6 +1185,18 @@
|
||||
>View {item.name} full size</span
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="Upload your own image"
|
||||
disabled={jstate === 'generating' || uploadingKey === itemKey(item.id)}
|
||||
onclick={() =>
|
||||
openOwnImage({ kind: 'item', id: item.id, name: item.name })}
|
||||
>
|
||||
<Icon name="upload" size={14} /><span class="sr-only"
|
||||
>Upload image for {item.name}</span
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -1145,6 +1266,17 @@
|
||||
? 'Regenerate image'
|
||||
: 'Generate image'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={jstate === 'generating' || uploadingKey === itemKey(item.id)}
|
||||
loading={uploadingKey === itemKey(item.id)}
|
||||
onclick={() =>
|
||||
openOwnImage({ kind: 'item', id: item.id, name: item.name })}
|
||||
>
|
||||
<Icon name="upload" size={13} />
|
||||
{item.reference_image_path ? 'Replace image' : 'Upload image'}
|
||||
</Button>
|
||||
{#if jstate === 'failed' && job}
|
||||
<Button
|
||||
variant="danger"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
|
||||
const items = [
|
||||
{ id: 'llm', href: '/settings?tab=llm', label: 'LLM', blurb: 'Model endpoint' },
|
||||
{ id: 'llm', href: '/settings?tab=llm', label: 'LLM', blurb: 'Model endpoints' },
|
||||
{ id: 'comfy', href: '/settings?tab=comfy', label: 'ComfyUI', blurb: 'Render farm' },
|
||||
{ id: 'workflows', href: '/settings?tab=workflows', label: 'Workflows', blurb: 'Library' },
|
||||
{ id: 'agent', href: '/settings?tab=agent', label: 'Agent', blurb: 'Hardening rules' },
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import { settings, type Settings } from '$lib/api';
|
||||
import { settings, type LlmProfile, type Settings } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
const client = useQueryClient();
|
||||
@@ -21,18 +21,18 @@
|
||||
});
|
||||
|
||||
let draft = $state<Record<string, unknown>>({});
|
||||
let apiKeyDraft = $state('');
|
||||
let apiKeyDrafts = $state<Record<string, string>>({});
|
||||
|
||||
// Dirty tracking: any staged field (draft map or a typed API key) counts.
|
||||
const dirtyKeys = $derived([
|
||||
...Object.keys(draft).filter((k) => draft[k] !== undefined),
|
||||
...(apiKeyDraft ? ['llm_api_key'] : []),
|
||||
...(Object.values(apiKeyDrafts).some((v) => v) ? ['llm_api_key'] : []),
|
||||
]);
|
||||
const isDirty = $derived(dirtyKeys.length > 0);
|
||||
|
||||
const FIELD_TAB: Record<string, string> = {
|
||||
llm_base_url: 'llm',
|
||||
llm_model: 'llm',
|
||||
llm_profiles: 'llm',
|
||||
llm_active_id: 'llm',
|
||||
llm_api_key: 'llm',
|
||||
comfyui_base_url: 'comfy',
|
||||
dry_run: 'comfy',
|
||||
@@ -78,13 +78,22 @@
|
||||
const err = fieldError(key);
|
||||
if (err) errors[key] = err;
|
||||
}
|
||||
const profiles = Array.isArray(draft.llm_profiles) ? (draft.llm_profiles as LlmProfile[]) : null;
|
||||
if (profiles) {
|
||||
if (profiles.length === 0) errors.llm_profiles = 'Add at least one LLM';
|
||||
for (const p of profiles) {
|
||||
if (!p.name.trim()) errors[`llm_name_${p.id}`] = 'Name is required';
|
||||
if (!p.base_url.trim()) errors[`llm_url_${p.id}`] = 'Base URL is required';
|
||||
if (!p.model.trim()) errors[`llm_model_${p.id}`] = 'Model is required';
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
});
|
||||
const isValid = $derived(Object.keys(validationErrors).length === 0);
|
||||
|
||||
function discardDraft() {
|
||||
draft = {};
|
||||
apiKeyDraft = '';
|
||||
apiKeyDrafts = {};
|
||||
}
|
||||
|
||||
const saveMutation = createMutation({
|
||||
@@ -92,6 +101,7 @@
|
||||
const update: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(draft)) {
|
||||
if (v === undefined) continue;
|
||||
if (k === 'llm_profiles' || k === 'llm_active_id') continue;
|
||||
// Allow false for dry_run; skip empty optional strings only.
|
||||
// agent_hardening_prompt may be emptied to disable hardening.
|
||||
if (v === '' && k !== 'dry_run' && k !== 'agent_hardening_prompt') continue;
|
||||
@@ -103,7 +113,22 @@
|
||||
}
|
||||
update[k] = v;
|
||||
}
|
||||
if (apiKeyDraft) update.llm_api_key = apiKeyDraft;
|
||||
if (Array.isArray(draft.llm_profiles)) {
|
||||
update.llm_profiles = (draft.llm_profiles as LlmProfile[]).map((p) => {
|
||||
const row: Record<string, unknown> = {
|
||||
id: p.id,
|
||||
name: p.name.trim(),
|
||||
base_url: p.base_url.trim(),
|
||||
model: p.model.trim(),
|
||||
};
|
||||
const key = apiKeyDrafts[p.id];
|
||||
if (key) row.api_key = key;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
if (typeof draft.llm_active_id === 'string' && draft.llm_active_id) {
|
||||
update.llm_active_id = draft.llm_active_id;
|
||||
}
|
||||
return settings.update(update);
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
@@ -188,6 +213,80 @@
|
||||
}
|
||||
return s.agent_hardening_prompt ?? '';
|
||||
}
|
||||
|
||||
function llmProfilesFromSettings(s: Settings): LlmProfile[] {
|
||||
if (s.llm_profiles?.length) return s.llm_profiles.map((p) => ({ ...p }));
|
||||
return [
|
||||
{
|
||||
id: s.llm_active_id || 'legacy',
|
||||
name: s.llm_model || 'Default',
|
||||
base_url: s.llm_base_url,
|
||||
model: s.llm_model,
|
||||
api_key: s.llm_api_key,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function workingProfiles(s: Settings): LlmProfile[] {
|
||||
if (Array.isArray(draft.llm_profiles)) return draft.llm_profiles as LlmProfile[];
|
||||
return llmProfilesFromSettings(s);
|
||||
}
|
||||
|
||||
function workingActiveId(s: Settings): string {
|
||||
if (typeof draft.llm_active_id === 'string' && draft.llm_active_id) {
|
||||
return draft.llm_active_id;
|
||||
}
|
||||
return s.llm_active_id || workingProfiles(s)[0]?.id || '';
|
||||
}
|
||||
|
||||
function ensureLlmDraft(s: Settings) {
|
||||
if (!Array.isArray(draft.llm_profiles)) {
|
||||
draft.llm_profiles = llmProfilesFromSettings(s);
|
||||
}
|
||||
if (typeof draft.llm_active_id !== 'string' || !draft.llm_active_id) {
|
||||
draft.llm_active_id = s.llm_active_id || (draft.llm_profiles as LlmProfile[])[0]?.id;
|
||||
}
|
||||
}
|
||||
|
||||
function patchProfile(s: Settings, id: string, patch: Partial<LlmProfile>) {
|
||||
ensureLlmDraft(s);
|
||||
draft.llm_profiles = (draft.llm_profiles as LlmProfile[]).map((p) =>
|
||||
p.id === id ? { ...p, ...patch } : p,
|
||||
);
|
||||
}
|
||||
|
||||
function setActiveProfile(s: Settings, id: string) {
|
||||
ensureLlmDraft(s);
|
||||
draft.llm_active_id = id;
|
||||
}
|
||||
|
||||
function addProfile(s: Settings) {
|
||||
ensureLlmDraft(s);
|
||||
const id = crypto.randomUUID();
|
||||
draft.llm_profiles = [
|
||||
...(draft.llm_profiles as LlmProfile[]),
|
||||
{
|
||||
id,
|
||||
name: 'New LLM',
|
||||
base_url: 'http://127.0.0.1:11434/v1',
|
||||
model: '',
|
||||
api_key: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function removeProfile(s: Settings, id: string) {
|
||||
ensureLlmDraft(s);
|
||||
const list = (draft.llm_profiles as LlmProfile[]).filter((p) => p.id !== id);
|
||||
if (list.length === 0) return;
|
||||
draft.llm_profiles = list;
|
||||
if (workingActiveId(s) === id) {
|
||||
draft.llm_active_id = list[0].id;
|
||||
}
|
||||
const nextKeys = { ...apiKeyDrafts };
|
||||
delete nextKeys[id];
|
||||
apiKeyDrafts = nextKeys;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onbeforeunload={onBeforeUnload} />
|
||||
@@ -210,36 +309,112 @@
|
||||
{@const s = $settingsQuery.data}
|
||||
{#if tab === 'llm'}
|
||||
<section class="panel">
|
||||
<h1>LLM</h1>
|
||||
<p class="lead">OpenAI-compatible chat endpoint used for story and script drafting.</p>
|
||||
<label class="field">
|
||||
<span class="field-label">Base URL</span>
|
||||
<input
|
||||
class="field-input"
|
||||
value={String(fieldValue('llm_base_url', s.llm_base_url))}
|
||||
oninput={(e) => (draft.llm_base_url = e.currentTarget.value)}
|
||||
placeholder="http://127.0.0.1:11434/v1"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Model</span>
|
||||
<input
|
||||
class="field-input"
|
||||
value={String(fieldValue('llm_model', s.llm_model))}
|
||||
oninput={(e) => (draft.llm_model = e.currentTarget.value)}
|
||||
placeholder="llama3.2"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">API key</span>
|
||||
<input
|
||||
class="field-input"
|
||||
type="password"
|
||||
bind:value={apiKeyDraft}
|
||||
placeholder={s.llm_api_key ? '•••••••• (saved)' : 'Optional for local servers'}
|
||||
/>
|
||||
<p class="field-hint">Stored in local config file, never in the project database.</p>
|
||||
</label>
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>LLM</h1>
|
||||
<p class="lead">
|
||||
OpenAI-compatible chat endpoints used for story, script, and the agent.
|
||||
Save several, then choose which one Calliope should use.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onclick={() => addProfile(s)}>
|
||||
<Icon name="plus" size={14} />
|
||||
Add LLM
|
||||
</Button>
|
||||
</div>
|
||||
{#if validationErrors.llm_profiles}
|
||||
<p class="field-error">{validationErrors.llm_profiles}</p>
|
||||
{/if}
|
||||
<div class="llm-list">
|
||||
{#each workingProfiles(s) as profile (profile.id)}
|
||||
{@const active = workingActiveId(s) === profile.id}
|
||||
<article class="llm-card" class:active>
|
||||
<header class="llm-card-head">
|
||||
<label class="llm-active">
|
||||
<input
|
||||
type="radio"
|
||||
name="llm-active"
|
||||
checked={active}
|
||||
onchange={() => setActiveProfile(s, profile.id)}
|
||||
/>
|
||||
<span>{active ? 'Active' : 'Use this'}</span>
|
||||
</label>
|
||||
{#if workingProfiles(s).length > 1}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Remove this LLM"
|
||||
onclick={() => removeProfile(s, profile.id)}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
Remove
|
||||
</Button>
|
||||
{/if}
|
||||
</header>
|
||||
<label class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<input
|
||||
class="field-input"
|
||||
class:invalid={validationErrors[`llm_name_${profile.id}`]}
|
||||
value={profile.name}
|
||||
oninput={(e) => patchProfile(s, profile.id, { name: e.currentTarget.value })}
|
||||
placeholder="Local Ollama"
|
||||
/>
|
||||
{#if validationErrors[`llm_name_${profile.id}`]}
|
||||
<p class="field-error">{validationErrors[`llm_name_${profile.id}`]}</p>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Base URL</span>
|
||||
<input
|
||||
class="field-input"
|
||||
class:invalid={validationErrors[`llm_url_${profile.id}`]}
|
||||
value={profile.base_url}
|
||||
oninput={(e) =>
|
||||
patchProfile(s, profile.id, { base_url: e.currentTarget.value })}
|
||||
placeholder="http://127.0.0.1:11434/v1"
|
||||
/>
|
||||
{#if validationErrors[`llm_url_${profile.id}`]}
|
||||
<p class="field-error">{validationErrors[`llm_url_${profile.id}`]}</p>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Model</span>
|
||||
<input
|
||||
class="field-input"
|
||||
class:invalid={validationErrors[`llm_model_${profile.id}`]}
|
||||
value={profile.model}
|
||||
oninput={(e) => patchProfile(s, profile.id, { model: e.currentTarget.value })}
|
||||
placeholder="llama3.2"
|
||||
/>
|
||||
{#if validationErrors[`llm_model_${profile.id}`]}
|
||||
<p class="field-error">{validationErrors[`llm_model_${profile.id}`]}</p>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">API key</span>
|
||||
<input
|
||||
class="field-input"
|
||||
type="password"
|
||||
value={apiKeyDrafts[profile.id] ?? ''}
|
||||
oninput={(e) => {
|
||||
ensureLlmDraft(s);
|
||||
apiKeyDrafts = {
|
||||
...apiKeyDrafts,
|
||||
[profile.id]: e.currentTarget.value,
|
||||
};
|
||||
}}
|
||||
placeholder={profile.api_key
|
||||
? '•••••••• (saved)'
|
||||
: 'Optional for local servers'}
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Stored in local config file, never in the project database.
|
||||
</p>
|
||||
</label>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else if tab === 'comfy'}
|
||||
<section class="panel">
|
||||
@@ -578,4 +753,56 @@
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.panel-head h1,
|
||||
.panel-head .lead {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.panel-head .lead {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.llm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.llm-card {
|
||||
padding: 14px 16px 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.llm-card.active {
|
||||
border-color: rgba(139, 92, 246, 0.45);
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
}
|
||||
.llm-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.llm-active {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.llm-card.active .llm-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.llm-active input {
|
||||
width: auto;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user