Merge pull request #26 from stefanljosbra/main

Upload button on Assets and multiple LLM endpoints in Settings
This commit is contained in:
Benji
2026-08-27 10:56:00 +08:00
committed by GitHub
8 changed files with 778 additions and 41 deletions
+147
View File
@@ -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()
+135
View File
@@ -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"]
+10
View File
@@ -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 &amp; 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' },
+264 -37
View File
@@ -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>