Create, duplicate, rename and delete presets from the panel
Each provider's preset list ends in "New ... preset...", and the preset editor gains Duplicate, Rename and Delete. New asks for a name and what to start from: a copy of one of that provider's presets, or a blank template. Delete is greyed out, with the reason, on the preset in use, and warns when it would leave a provider with no preset at all. CLI: - preset rename <name> <new>: the new name is hard-linked in, state.json is repointed, and only then does the old name go. - preset new <name> --provider <p> [--blank]: with a provider and no source it copies that provider's own default instead of the OpenRouter-only `default`; --blank starts from the scaffold. - valid_preset_name on every preset subcommand: no slash, no leading dot. `preset show ../../etc/passwd` used to print the file. - preset rm warns when it removes a provider's last preset. - preflight refuses a preset with every tier empty. Otherwise a blank preset would switch cleanly and Claude Code would ask the gateway for its default Anthropic models, billed at full price on OpenRouter. The panel's blocked card offers "Edit preset..." for it. The key helper now reads the active preset in a single open (new cm-json auth-of) and re-reads state.json once on a miss. Renaming the active preset could otherwise catch a running session between reading the old name and opening the file: 1 failure in 51 key fetches in a race test before, 0 in 118 across 60 renames after. It could also briefly hand out the openrouter key for a preset that uses another. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -181,7 +181,10 @@ claude-mode lmstudio [preset] default preset: lmstudio
|
|||||||
|
|
||||||
claude-mode presets list presets (* = active)
|
claude-mode presets list presets (* = active)
|
||||||
claude-mode preset show <name>
|
claude-mode preset show <name>
|
||||||
claude-mode preset new <name> [from] copy an existing preset
|
claude-mode preset new <name> [from] copy an existing preset (default: `default`)
|
||||||
|
claude-mode preset new <name> --provider <p> [--blank]
|
||||||
|
copy that provider's default, or start empty
|
||||||
|
claude-mode preset rename <name> <new-name> repoints state.json if it is in use
|
||||||
claude-mode preset set <name> <tier> <model-id>
|
claude-mode preset set <name> <tier> <model-id>
|
||||||
claude-mode preset all <name> <model-id> point every tier at one model
|
claude-mode preset all <name> <model-id> point every tier at one model
|
||||||
claude-mode preset rm <name>
|
claude-mode preset rm <name>
|
||||||
|
|||||||
@@ -28,17 +28,24 @@ mode="$("$PY" "$JSON" get "$state" mode 2>/dev/null)"
|
|||||||
[ "$mode" = "anthropic" ] && exit 0
|
[ "$mode" = "anthropic" ] && exit 0
|
||||||
[ -n "$mode" ] || exit 0
|
[ -n "$mode" ] || exit 0
|
||||||
|
|
||||||
|
# `preset rename` on the active preset moves the file while this may be running
|
||||||
|
# in any live session. The name read from state.json can be the old one by the
|
||||||
|
# time the file is opened, so the preset is read in a single open (a removal
|
||||||
|
# after that cannot change what was read), and a miss re-reads state.json once:
|
||||||
|
# the rename repoints it before removing the old name, so the second read finds
|
||||||
|
# the new one. A second miss is a real fault.
|
||||||
|
auth=''
|
||||||
|
for _attempt in 1 2; do
|
||||||
preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)"
|
preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)"
|
||||||
[ -n "$preset_name" ] || exit 0
|
[ -n "$preset_name" ] || exit 0
|
||||||
|
|
||||||
preset="$CM_ROOT/presets/$preset_name.json"
|
preset="$CM_ROOT/presets/$preset_name.json"
|
||||||
[ -f "$preset" ] || fail "state.json names preset '$preset_name' but $preset does not exist. Run: claude-mode presets"
|
auth="$("$PY" "$JSON" auth-of "$preset" 2>/dev/null)" && break
|
||||||
|
auth=''
|
||||||
|
done
|
||||||
|
[ -n "$auth" ] || fail "state.json names preset '$preset_name' but $preset does not exist. Run: claude-mode presets"
|
||||||
|
|
||||||
auth_mode="$("$PY" "$JSON" get "$preset" auth.mode 2>/dev/null)"
|
auth_mode="${auth%%$'\t'*}"
|
||||||
[ -z "$auth_mode" ] && auth_mode="vault"
|
key_ref="${auth#*$'\t'}"
|
||||||
[ "$auth_mode" = "vault" ] || exit 0 # inline token: nothing for us to emit
|
[ "$auth_mode" = "vault" ] || exit 0 # inline token: nothing for us to emit
|
||||||
|
|
||||||
key_ref="$("$PY" "$JSON" get "$preset" auth.keyRef 2>/dev/null)"
|
|
||||||
[ -n "$key_ref" ] || key_ref="openrouter"
|
|
||||||
|
|
||||||
cm_vault_get "$key_ref" || fail "no key readable for ref '$key_ref' from $(cm_vault_backend_label 2>/dev/null || echo 'the vault'). Run: claude-mode set-key $key_ref"
|
cm_vault_get "$key_ref" || fail "no key readable for ref '$key_ref' from $(cm_vault_backend_label 2>/dev/null || echo 'the vault'). Run: claude-mode set-key $key_ref"
|
||||||
|
|||||||
+95
-6
@@ -265,7 +265,10 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
|
|||||||
|
|
||||||
claude-mode presets list presets
|
claude-mode presets list presets
|
||||||
claude-mode preset show <name>
|
claude-mode preset show <name>
|
||||||
claude-mode preset new <name> [from] create a preset (copies 'from')
|
claude-mode preset new <name> [from] create a preset (copies 'from', else 'default')
|
||||||
|
claude-mode preset new <name> --provider <p> [--blank]
|
||||||
|
copy that provider's default, or start empty
|
||||||
|
claude-mode preset rename <name> <new-name>
|
||||||
claude-mode preset set <name> <tier> <model-id>
|
claude-mode preset set <name> <tier> <model-id>
|
||||||
claude-mode preset all <name> <model-id>
|
claude-mode preset all <name> <model-id>
|
||||||
claude-mode preset url <name> <base-url> point a preset at another server
|
claude-mode preset url <name> <base-url> point a preset at another server
|
||||||
@@ -310,6 +313,16 @@ state_preset() { jget "$CM_STATE" preset; }
|
|||||||
|
|
||||||
preset_path() { printf '%s/%s.json' "$CM_PRESETS" "$1"; }
|
preset_path() { printf '%s/%s.json' "$CM_PRESETS" "$1"; }
|
||||||
|
|
||||||
|
# Preset names become file names, so nothing but a plain word gets through - no
|
||||||
|
# slash, and no leading dot. Without this `preset show ../../etc/passwd`
|
||||||
|
# printed whatever it pointed at.
|
||||||
|
valid_preset_name() {
|
||||||
|
case "$1" in
|
||||||
|
''|.*|*[!A-Za-z0-9._-]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
preset_names() { "$PY" "$JSON" presets "$CM_PRESETS" | cut -f1; }
|
preset_names() { "$PY" "$JSON" presets "$CM_PRESETS" | cut -f1; }
|
||||||
|
|
||||||
# Deliberately no awk anywhere in this script: it is absent from minimal images
|
# Deliberately no awk anywhere in this script: it is absent from minimal images
|
||||||
@@ -476,6 +489,18 @@ cm_preflight() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A preset with no tier mapped - a fresh `preset new --blank` - would switch
|
||||||
|
# cleanly and leave Claude Code asking the gateway for its own default
|
||||||
|
# Anthropic models: billed at full list price on OpenRouter, refused by the
|
||||||
|
# others. Nothing else here would catch it, since the cost guard only sees
|
||||||
|
# ids that are actually set.
|
||||||
|
if ! "$PY" "$JSON" models "$pf" 2>/dev/null | grep -v '^subagent' | cut -f2 | grep -q .; then
|
||||||
|
cm_pf_set 'no-models' "Preset '$preset' has no models set" \
|
||||||
|
"Every tier is empty, so Claude Code would ask for its own default Anthropic models instead - billed at full price through OpenRouter, refused by the other providers." \
|
||||||
|
"claude-mode preset set $preset <tier> <model-id>" 'edit-preset'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
|
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
|
||||||
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
|
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
|
||||||
|
|
||||||
@@ -1508,26 +1533,92 @@ else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim,
|
|||||||
|
|
||||||
cmd_preset() {
|
cmd_preset() {
|
||||||
local sub="${1:-}" name="${2:-}"
|
local sub="${1:-}" name="${2:-}"
|
||||||
|
if [ -n "$name" ] && ! valid_preset_name "$name"; then
|
||||||
|
err "invalid preset name '$name' - letters, digits, . _ and - only, not starting with a dot"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
case "$sub" in
|
case "$sub" in
|
||||||
show)
|
show)
|
||||||
[ -n "$name" ] || { err 'usage: claude-mode preset show <name>'; return 1; }
|
[ -n "$name" ] || { err 'usage: claude-mode preset show <name>'; return 1; }
|
||||||
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
cat "$(preset_path "$name")"
|
cat "$(preset_path "$name")"
|
||||||
;;
|
;;
|
||||||
new)
|
new)
|
||||||
[ -n "$name" ] || { err 'usage: claude-mode preset new <name> [copy-from]'; return 1; }
|
[ -n "$name" ] || { err 'usage: claude-mode preset new <name> [from] | <name> --provider <p> [--blank]'; return 1; }
|
||||||
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return 1; }
|
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return 1; }
|
||||||
local from="${3:-default}"
|
local from='' provider='' blank=0 a
|
||||||
|
shift 2
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
a="$1"; shift
|
||||||
|
case "$a" in
|
||||||
|
--provider)
|
||||||
|
[ $# -gt 0 ] || { err '--provider needs openrouter, zai or lmstudio'; return 1; }
|
||||||
|
provider="$1"; shift ;;
|
||||||
|
--blank) blank=1 ;;
|
||||||
|
-*) err "unknown option '$a'"; return 1 ;;
|
||||||
|
*) from="$a" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
case "$provider" in
|
||||||
|
''|openrouter|zai|lmstudio) ;;
|
||||||
|
*) err "unknown provider '$provider' - openrouter, zai or lmstudio"; return 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$blank" -eq 1 ]; then
|
||||||
|
[ -n "$provider" ] || { err '--blank needs --provider to know which template'; return 1; }
|
||||||
|
[ -z "$from" ] || { err '--blank starts from nothing; drop the source preset'; return 1; }
|
||||||
|
"$PY" "$JSON" scaffold "$provider" > "$(preset_path "$name")" || {
|
||||||
|
rm -f "$(preset_path "$name")"; return 1; }
|
||||||
|
ok "created blank $provider preset '$name'"
|
||||||
|
say 'every tier is empty: set them with claude-mode preset set, or the bar panel'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A literal `default` is only right for OpenRouter. Given a provider
|
||||||
|
# and no source, start from that provider's own default preset.
|
||||||
|
if [ -z "$from" ]; then
|
||||||
|
if [ -n "$provider" ]; then from="$(resolve_preset "$provider")" || return 1
|
||||||
|
else from=default; fi
|
||||||
|
fi
|
||||||
|
valid_preset_name "$from" || { err "invalid source preset name '$from'"; return 1; }
|
||||||
[ -f "$(preset_path "$from")" ] || { err "source preset '$from' not found"; return 1; }
|
[ -f "$(preset_path "$from")" ] || { err "source preset '$from' not found"; return 1; }
|
||||||
|
if [ -n "$provider" ]; then
|
||||||
|
local got; got="$(jget "$(preset_path "$from")" provider)"; [ -z "$got" ] && got=openrouter
|
||||||
|
[ "$got" = "$provider" ] || { err "'$from' is for $got, not $provider"; return 1; }
|
||||||
|
fi
|
||||||
cp "$(preset_path "$from")" "$(preset_path "$name")"
|
cp "$(preset_path "$from")" "$(preset_path "$name")"
|
||||||
ok "created $(preset_path "$name") from '$from'"
|
ok "created $(preset_path "$name") from '$from'"
|
||||||
;;
|
;;
|
||||||
|
rename)
|
||||||
|
local new="${3:-}" out
|
||||||
|
[ -n "$name" ] && [ -n "$new" ] || { err 'usage: claude-mode preset rename <name> <new-name>'; return 1; }
|
||||||
|
valid_preset_name "$new" || { err "invalid preset name '$new' - letters, digits, . _ and - only, not starting with a dot"; return 1; }
|
||||||
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
|
[ "$name" = "$new" ] && { ok "already called '$new'"; return 0; }
|
||||||
|
[ -e "$(preset_path "$new")" ] && { err "preset '$new' already exists"; return 1; }
|
||||||
|
out="$("$PY" "$JSON" preset-rename "$CM_PRESETS" "$name" "$new" "$CM_STATE" 2>&1)" || {
|
||||||
|
err "$out"; return 1; }
|
||||||
|
ok "renamed '$name' -> '$new'"
|
||||||
|
case "$out" in
|
||||||
|
*'"active": true'*)
|
||||||
|
say 'it is the active preset; state.json now names it, and running sessions keep working'
|
||||||
|
write_health "$(state_mode)" "$new" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
rm)
|
rm)
|
||||||
[ -n "$name" ] || { err 'usage: claude-mode preset rm <name>'; return 1; }
|
[ -n "$name" ] || { err 'usage: claude-mode preset rm <name>'; return 1; }
|
||||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
if [ "$name" = "$(state_preset)" ] && [ "$(state_mode)" != "anthropic" ]; then
|
if [ "$name" = "$(state_preset)" ] && [ "$(state_mode)" != "anthropic" ]; then
|
||||||
err "preset '$name' is active. Switch away first."; return 1
|
err "preset '$name' is active. Switch away first."; return 1
|
||||||
fi
|
fi
|
||||||
|
local prov; prov="$(jget "$(preset_path "$name")" provider)"; [ -z "$prov" ] && prov=openrouter
|
||||||
rm -f "$(preset_path "$name")"; ok "deleted preset '$name'"
|
rm -f "$(preset_path "$name")"; ok "deleted preset '$name'"
|
||||||
|
# Allowed, but not quietly: with no preset left, `claude-mode <provider>`
|
||||||
|
# has nothing to resolve to.
|
||||||
|
if [ -z "$(presets_for_provider "$prov")" ]; then
|
||||||
|
warn "that was the last $prov preset - 'claude-mode $prov' has nothing to switch to now"
|
||||||
|
say "create one with: claude-mode preset new <name> --provider $prov [--blank]"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
set)
|
set)
|
||||||
local tier="${3:-}" model="${4:-}"
|
local tier="${3:-}" model="${4:-}"
|
||||||
@@ -1935,9 +2026,7 @@ ui_new_preset() {
|
|||||||
printf ' name (letters, digits, dash; blank = cancel): '
|
printf ' name (letters, digits, dash; blank = cancel): '
|
||||||
local name; IFS= read -r name
|
local name; IFS= read -r name
|
||||||
[ -n "$name" ] || return
|
[ -n "$name" ] || return
|
||||||
case "$name" in
|
valid_preset_name "$name" || { err "invalid name '$name'"; return; }
|
||||||
*[!A-Za-z0-9._-]*) err "invalid name '$name'"; return ;;
|
|
||||||
esac
|
|
||||||
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return; }
|
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return; }
|
||||||
|
|
||||||
if [ "$choice" -eq 0 ]; then
|
if [ "$choice" -eq 0 ]; then
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ Subcommands:
|
|||||||
scan-sessions <projects> [max-age-days] [ignored] broken transcripts, as JSON
|
scan-sessions <projects> [max-age-days] [ignored] broken transcripts, as JSON
|
||||||
ignore-session <ignored> <projects> add|remove|clear|list [id]
|
ignore-session <ignored> <projects> add|remove|clear|list [id]
|
||||||
cache-models <cache> <provider> <1|0> [baseUrl] store a TSV catalogue (stdin)
|
cache-models <cache> <provider> <1|0> [baseUrl] store a TSV catalogue (stdin)
|
||||||
|
preset-rename <presets-dir> <old> <new> <state> rename, repointing state.json
|
||||||
|
auth-of <preset> "mode<TAB>keyRef"; fails if missing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
@@ -250,6 +252,53 @@ def cmd_scaffold(argv):
|
|||||||
print(json.dumps(base, indent=2))
|
print(json.dumps(base, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_auth_of(argv):
|
||||||
|
"""auth-of <preset> - 'mode<TAB>keyRef' for the key helper, defaults filled in.
|
||||||
|
|
||||||
|
Exits non-zero when the file is missing, which load() would otherwise
|
||||||
|
hide as an empty preset - and an empty preset reads as vault/openrouter,
|
||||||
|
so a preset renamed mid-read would quietly hand out the wrong key.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(argv[0], encoding="utf-8") as fh:
|
||||||
|
text = fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
sys.exit(1)
|
||||||
|
p = json.loads(text) if text else {}
|
||||||
|
auth = p.get("auth") or {}
|
||||||
|
print("%s\t%s" % (auth.get("mode") or "vault", auth.get("keyRef") or "openrouter"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_preset_rename(argv):
|
||||||
|
"""preset-rename <presets-dir> <old> <new> <state.json>
|
||||||
|
|
||||||
|
Every running session's key helper resolves the active preset through
|
||||||
|
state.json on each key fetch, so at no instant may state.json name a file
|
||||||
|
that is not there. The new name is linked in first, state.json repointed,
|
||||||
|
and only then does the old name go. Prints {"renamed", "active"}.
|
||||||
|
"""
|
||||||
|
d, old, new, state_path = argv[:4]
|
||||||
|
src = os.path.join(d, old + ".json")
|
||||||
|
dst = os.path.join(d, new + ".json")
|
||||||
|
if not os.path.isfile(src):
|
||||||
|
raise SystemExit("preset '%s' not found" % old)
|
||||||
|
if os.path.exists(dst):
|
||||||
|
raise SystemExit("preset '%s' already exists" % new)
|
||||||
|
try:
|
||||||
|
os.link(src, dst)
|
||||||
|
except OSError:
|
||||||
|
__import__("shutil").copy2(src, dst) # filesystems without hard links
|
||||||
|
|
||||||
|
state = load(state_path, {})
|
||||||
|
active = isinstance(state, dict) and state.get("preset") == old
|
||||||
|
if active:
|
||||||
|
state["preset"] = new
|
||||||
|
save(state_path, state)
|
||||||
|
|
||||||
|
os.unlink(src)
|
||||||
|
print(json.dumps({"renamed": True, "active": active}))
|
||||||
|
|
||||||
|
|
||||||
def cmd_get(argv):
|
def cmd_get(argv):
|
||||||
data = load(argv[0])
|
data = load(argv[0])
|
||||||
cur = data
|
cur = data
|
||||||
@@ -1176,6 +1225,8 @@ COMMANDS = {
|
|||||||
"set-all": cmd_set_all,
|
"set-all": cmd_set_all,
|
||||||
"set-auth": cmd_set_auth,
|
"set-auth": cmd_set_auth,
|
||||||
"scaffold": cmd_scaffold,
|
"scaffold": cmd_scaffold,
|
||||||
|
"preset-rename": cmd_preset_rename,
|
||||||
|
"auth-of": cmd_auth_of,
|
||||||
"get": cmd_get,
|
"get": cmd_get,
|
||||||
"presets": cmd_presets,
|
"presets": cmd_presets,
|
||||||
"managed": cmd_managed,
|
"managed": cmd_managed,
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ Panel {
|
|||||||
// Both questions are answered by claude-mode rather than re-derived here, so
|
// Both questions are answered by claude-mode rather than re-derived here, so
|
||||||
// the widget and the CLI can never disagree about whether a switch is allowed
|
// the widget and the CLI can never disagree about whether a switch is allowed
|
||||||
// or about what is running.
|
// or about what is running.
|
||||||
property string stage: "list" // list | blocked | confirm | server | repair | preset | presetTier
|
property string stage: "list" // list | blocked | confirm | server | repair
|
||||||
|
// | preset | presetTier | presetNew | presetRename | presetDelete
|
||||||
property var blocker: null // the preflight verdict, when it refused
|
property var blocker: null // the preflight verdict, when it refused
|
||||||
property var sessionInfo: null // sessions --json, when any were found
|
property var sessionInfo: null // sessions --json, when any were found
|
||||||
property string pendingMode: ""
|
property string pendingMode: ""
|
||||||
@@ -239,13 +240,15 @@ Panel {
|
|||||||
property var opQueue: []
|
property var opQueue: []
|
||||||
property string opLabel: ""
|
property string opLabel: ""
|
||||||
property string opLanding: ""
|
property string opLanding: ""
|
||||||
|
property var opThen: null // run on success, before landing
|
||||||
|
|
||||||
function runOps(label, ops, landing) {
|
function runOps(label, ops, landing, then) {
|
||||||
if (root.busy) return
|
if (root.busy) return
|
||||||
root.busy = true
|
root.busy = true
|
||||||
root.lastError = ""
|
root.lastError = ""
|
||||||
root.opLabel = label
|
root.opLabel = label
|
||||||
root.opLanding = landing
|
root.opLanding = landing
|
||||||
|
root.opThen = then || null
|
||||||
root.opQueue = ops.slice()
|
root.opQueue = ops.slice()
|
||||||
root.nextOp()
|
root.nextOp()
|
||||||
}
|
}
|
||||||
@@ -282,7 +285,12 @@ Panel {
|
|||||||
if (opProc.finalHealth) {
|
if (opProc.finalHealth) {
|
||||||
root.busy = false
|
root.busy = false
|
||||||
if (widget) widget.refresh()
|
if (widget) widget.refresh()
|
||||||
if (root.lastError === "") root.stage = root.opLanding
|
var then = root.opThen
|
||||||
|
root.opThen = null
|
||||||
|
if (root.lastError === "") {
|
||||||
|
if (then) then()
|
||||||
|
root.stage = root.opLanding
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
@@ -414,6 +422,80 @@ Panel {
|
|||||||
return Math.round(s / 86400) + " days ago"
|
return Math.round(s / 86400) + " days ago"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Preset lifecycle: new, duplicate, rename, delete
|
||||||
|
//
|
||||||
|
// Names become file names, so the CLI's valid_preset_name rule is checked
|
||||||
|
// here too. The CLI checks again; this only says what is wrong while typing.
|
||||||
|
readonly property var presetNameRe: /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/
|
||||||
|
property string newProvider: ""
|
||||||
|
property string newFrom: "" // source preset to copy; "" = blank template
|
||||||
|
property string newName: ""
|
||||||
|
property string renameTo: ""
|
||||||
|
|
||||||
|
function presetsOf(provider) {
|
||||||
|
var all = (health && health.presets) ? health.presets : []
|
||||||
|
return all.filter(function (p) { return String(p.provider) === provider })
|
||||||
|
}
|
||||||
|
|
||||||
|
// `keep` is a name that is allowed to exist already - the preset's own,
|
||||||
|
// when renaming.
|
||||||
|
function nameProblem(name, keep) {
|
||||||
|
var n = String(name || "").trim()
|
||||||
|
if (n === "") return "Give it a name."
|
||||||
|
if (!root.presetNameRe.test(n)) return "Letters, digits, . _ and - only, not starting with a dot."
|
||||||
|
if (n !== keep && root.presetEntry(n)) return "There is already a preset called '" + n + "'."
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(base) {
|
||||||
|
var n = base
|
||||||
|
for (var i = 2; root.presetEntry(n); i++) n = base + "-" + i
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// `from` null means "whatever makes sense": the provider's first preset,
|
||||||
|
// which carries a working server, key and context, else a blank template.
|
||||||
|
function openNewPreset(provider, from) {
|
||||||
|
root.lastError = ""
|
||||||
|
root.newProvider = provider
|
||||||
|
var sibs = root.presetsOf(provider)
|
||||||
|
root.newFrom = from !== null && from !== undefined ? from : (sibs.length > 0 ? String(sibs[0].name) : "")
|
||||||
|
root.newName = root.uniqueName(from ? from + "-copy" : provider + "-new")
|
||||||
|
newNameField.text = root.newName
|
||||||
|
root.stage = "presetNew"
|
||||||
|
Qt.callLater(function () { newNameField.forceActiveFocus(); newNameField.selectAll() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreset() {
|
||||||
|
var n = root.newName.trim()
|
||||||
|
if (root.nameProblem(n, "") !== "") return
|
||||||
|
var argv = root.newFrom === ""
|
||||||
|
? ["preset", "new", n, "--provider", root.newProvider, "--blank"]
|
||||||
|
: ["preset", "new", n, root.newFrom]
|
||||||
|
root.runOps("creating", [argv], "preset", function () { root.editPreset = n })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRename() {
|
||||||
|
root.lastError = ""
|
||||||
|
root.renameTo = root.editPreset
|
||||||
|
renameField.text = root.editPreset
|
||||||
|
root.stage = "presetRename"
|
||||||
|
Qt.callLater(function () { renameField.forceActiveFocus(); renameField.selectAll() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function renamePreset() {
|
||||||
|
var n = root.renameTo.trim()
|
||||||
|
if (n === root.editPreset) { root.stage = "preset"; return }
|
||||||
|
if (root.nameProblem(n, root.editPreset) !== "") return
|
||||||
|
var old = root.editPreset
|
||||||
|
root.runOps("renaming", [["preset", "rename", old, n]], "preset", function () { root.editPreset = n })
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePreset() {
|
||||||
|
if (root.editIsActive || root.editPreset === "") return
|
||||||
|
root.runOps("deleting", [["preset", "rm", root.editPreset]], "list", function () { root.editPreset = "" })
|
||||||
|
}
|
||||||
|
|
||||||
function storeServerKey() {
|
function storeServerKey() {
|
||||||
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
|
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
|
||||||
remedyProc.running = true
|
remedyProc.running = true
|
||||||
@@ -430,6 +512,7 @@ Panel {
|
|||||||
root.repairTarget = null
|
root.repairTarget = null
|
||||||
root.editPreset = ""
|
root.editPreset = ""
|
||||||
root.editTier = ""
|
root.editTier = ""
|
||||||
|
root.newProvider = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchTo(mode, presetName) {
|
function switchTo(mode, presetName) {
|
||||||
@@ -622,6 +705,8 @@ Panel {
|
|||||||
implicitWidth: pbText.implicitWidth + Style.space(20)
|
implicitWidth: pbText.implicitWidth + Style.space(20)
|
||||||
implicitHeight: Style.space(25)
|
implicitHeight: Style.space(25)
|
||||||
radius: Style.space(4)
|
radius: Style.space(4)
|
||||||
|
// `enabled: false` greys it out; the MouseArea inherits the flag.
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
|
||||||
readonly property color tint: primary ? Color.accent : Color.popups.text
|
readonly property color tint: primary ? Color.accent : Color.popups.text
|
||||||
color: pbArea.containsMouse
|
color: pbArea.containsMouse
|
||||||
@@ -843,10 +928,11 @@ Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Presets for this provider, unfolded in place.
|
// ---- Presets for this provider, unfolded in place. Shown even
|
||||||
|
// with none left, so "New preset…" is still there to click.
|
||||||
Column {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: modeEntry.isExpanded && modeEntry.presetList.length > 0
|
visible: modeEntry.isExpanded
|
||||||
spacing: Style.space(1)
|
spacing: Style.space(1)
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
@@ -941,6 +1027,40 @@ Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: modeEntry.width
|
||||||
|
height: Style.space(24)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: Style.space(20)
|
||||||
|
anchors.rightMargin: -Style.space(6)
|
||||||
|
radius: Style.space(4)
|
||||||
|
color: newPresetArea.containsMouse
|
||||||
|
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
|
||||||
|
: "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Style.space(30)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "New " + Modes.title(modeEntry.thisMode) + " preset…"
|
||||||
|
color: newPresetArea.containsMouse ? Color.accent : Color.muted
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(11)
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: newPresetArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
enabled: !root.busy
|
||||||
|
onClicked: root.openNewPreset(modeEntry.thisMode, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1003,6 +1123,18 @@ Panel {
|
|||||||
visible: root.blocker && String(root.blocker.remedyKind) === "set-key"
|
visible: root.blocker && String(root.blocker.remedyKind) === "set-key"
|
||||||
onTriggered: root.runRemedy()
|
onTriggered: root.runRemedy()
|
||||||
}
|
}
|
||||||
|
// A preset with every tier empty: the fix is one click away here,
|
||||||
|
// not in a terminal.
|
||||||
|
PillButton {
|
||||||
|
label: "Edit preset…"
|
||||||
|
primary: true
|
||||||
|
visible: root.blocker && String(root.blocker.remedyKind) === "edit-preset"
|
||||||
|
onTriggered: {
|
||||||
|
var p = root.pendingPreset
|
||||||
|
root.resetFlow()
|
||||||
|
root.openPresetEditor(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
PillButton {
|
PillButton {
|
||||||
label: "Server settings…"
|
label: "Server settings…"
|
||||||
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0
|
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0
|
||||||
@@ -1260,8 +1392,231 @@ Panel {
|
|||||||
visible: root.editProvider === "lmstudio"
|
visible: root.editProvider === "lmstudio"
|
||||||
onTriggered: root.openServerSettings(root.editPreset, "preset")
|
onTriggered: root.openServerSettings(root.editPreset, "preset")
|
||||||
}
|
}
|
||||||
|
PillButton { label: "Duplicate…"; onTriggered: root.openNewPreset(root.editProvider, root.editPreset) }
|
||||||
|
PillButton { label: "Rename…"; onTriggered: root.openRename() }
|
||||||
|
// The CLI refuses to delete the preset in use. A button that always
|
||||||
|
// fails would be worse than one that says why it is off.
|
||||||
|
PillButton {
|
||||||
|
label: "Delete…"
|
||||||
|
enabled: !root.editIsActive
|
||||||
|
onTriggered: { root.lastError = ""; root.stage = "presetDelete" }
|
||||||
|
}
|
||||||
PillButton { label: "Done"; primary: true; onTriggered: root.resetFlow() }
|
PillButton { label: "Done"; primary: true; onTriggered: root.resetFlow() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.editIsActive
|
||||||
|
text: "In use, so it cannot be deleted. Switch to another preset first."
|
||||||
|
color: Color.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- New preset: a name, and what to start from.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.stage === "presetNew"
|
||||||
|
spacing: Style.space(7)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "New " + Modes.title(root.newProvider) + " preset"
|
||||||
|
color: Color.popups.text
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(13)
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: newNameField
|
||||||
|
width: parent.width
|
||||||
|
text: root.newName
|
||||||
|
placeholderText: "name"
|
||||||
|
foreground: Color.popups.text
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(11)
|
||||||
|
onTextChanged: root.newName = text
|
||||||
|
onAccepted: root.createPreset()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
readonly property string problem: root.stage === "presetNew" ? root.nameProblem(root.newName, "") : ""
|
||||||
|
visible: problem !== ""
|
||||||
|
text: problem
|
||||||
|
color: root.urgentColor
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Start from"
|
||||||
|
color: Color.muted
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only this provider's presets: a copy keeps the provider, and a
|
||||||
|
// preset cannot change provider afterwards.
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(6)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.stage === "presetNew" ? root.presetsOf(root.newProvider) : []
|
||||||
|
|
||||||
|
PillButton {
|
||||||
|
required property var modelData
|
||||||
|
label: "copy of " + String(modelData.name)
|
||||||
|
primary: root.newFrom === String(modelData.name)
|
||||||
|
onTriggered: root.newFrom = String(modelData.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PillButton {
|
||||||
|
label: "blank"
|
||||||
|
primary: root.newFrom === ""
|
||||||
|
onTriggered: root.newFrom = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: newFromHint
|
||||||
|
width: parent.width
|
||||||
|
text: root.newFrom === ""
|
||||||
|
? "Every tier starts empty; the editor opens next to fill them in."
|
||||||
|
: "Same server, key and models as '" + root.newFrom + "'. The editor opens next to change them."
|
||||||
|
color: Color.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(7)
|
||||||
|
|
||||||
|
PillButton { label: "Create"; primary: true; onTriggered: root.createPreset() }
|
||||||
|
PillButton {
|
||||||
|
label: "Cancel"
|
||||||
|
onTriggered: {
|
||||||
|
root.lastError = ""
|
||||||
|
if (root.editPreset !== "") root.stage = "preset"
|
||||||
|
else root.resetFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rename: the file moves, and state.json follows if it is in use.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.stage === "presetRename"
|
||||||
|
spacing: Style.space(7)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Rename '" + root.editPreset + "'"
|
||||||
|
color: Color.popups.text
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(13)
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: renameField
|
||||||
|
width: parent.width
|
||||||
|
text: root.renameTo
|
||||||
|
placeholderText: "new name"
|
||||||
|
foreground: Color.popups.text
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(11)
|
||||||
|
onTextChanged: root.renameTo = text
|
||||||
|
onAccepted: root.renamePreset()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
readonly property string problem: root.stage === "presetRename" ? root.nameProblem(root.renameTo, root.editPreset) : ""
|
||||||
|
visible: problem !== ""
|
||||||
|
text: problem
|
||||||
|
color: root.urgentColor
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.editIsActive
|
||||||
|
text: "It is the preset in use. The switch follows the new name, and running sessions keep working."
|
||||||
|
color: Color.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(7)
|
||||||
|
|
||||||
|
PillButton { label: "Rename"; primary: true; onTriggered: root.renamePreset() }
|
||||||
|
PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Delete: confirmed, and says so when it leaves a provider empty.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.stage === "presetDelete"
|
||||||
|
spacing: Style.space(7)
|
||||||
|
|
||||||
|
readonly property bool lastOne: root.stage === "presetDelete"
|
||||||
|
&& root.presetsOf(root.editProvider).length <= 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Delete '" + root.editPreset + "'?"
|
||||||
|
color: Color.popups.text
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(13)
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "The preset file is removed, and there is no undo from here. Its key stays in the vault."
|
||||||
|
color: Color.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: parent.lastOne
|
||||||
|
text: "It is the only " + Modes.title(root.editProvider) + " preset, so "
|
||||||
|
+ Modes.title(root.editProvider) + " will have nothing to switch to until you create another."
|
||||||
|
color: root.urgentColor
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||||
|
font.pixelSize: Style.space(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(7)
|
||||||
|
|
||||||
|
PillButton { label: "Delete it"; primary: true; onTriggered: root.deletePreset() }
|
||||||
|
PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- One tier: pick from the cached catalogue, or type any id.
|
// ---- One tier: pick from the cached catalogue, or type any id.
|
||||||
|
|||||||
Reference in New Issue
Block a user