hendrik/interfaces/tui/moopui.py

510 lines
17 KiB
Python

# moopui.py — Popup manajemen Model Option (moop) untuk TUI.
#
# Menyediakan:
# * model_selector_popup — pilih default model set tipe 'llm'
# * sets_manage_popup — CRUD model set
# * set_detail_popup — kelola type dalam sebuah set
# * set_models_popup — kelola model (provider/model) dalam sebuah set
# * providers_manage_popup — CRUD provider (moop_api)
# * provider_detail_popup — kelola API key per provider
import curses
from . import keycodes, theme
from .agent import log
from lib import moop
MODEL_TYPES = moop.MODEL_TYPES
def _mask_key(k):
if not k:
return "(empty)"
if len(k) <= 8:
return "*" * len(k)
return k[:4] + "..." + k[-4:]
def _parse_priority(text):
if text is None or text.strip() == "":
return None
try:
return int(text.strip())
except ValueError:
return None
def _text_input(app, stdscr, title, initial="", width=60):
pw = min(width, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
buf = initial
pos = len(buf)
max_chars = pw - 4
result = None
while True:
win.erase()
win.box()
try:
win.addstr(0, 2, f" {title} ", curses.A_BOLD)
except curses.error:
pass
start = max(0, pos - (max_chars - 1)) if pos > max_chars - 1 else 0
shown = buf[start:start + max_chars]
try:
win.addstr(1, 2, shown.ljust(max_chars))
except curses.error:
pass
try:
win.move(1, 2 + (pos - start))
except curses.error:
pass
win.refresh()
key = keycodes.read_key(stdscr, -1)
if key in (curses.KEY_ENTER, 10, 13):
result = buf
break
elif key in (27,):
break
elif key in (curses.KEY_BACKSPACE, 127):
if pos > 0:
buf = buf[:pos - 1] + buf[pos:]
pos -= 1
elif key == curses.KEY_DC:
if pos < len(buf):
buf = buf[:pos] + buf[pos + 1:]
elif key == curses.KEY_LEFT:
pos = max(0, pos - 1)
elif key == curses.KEY_RIGHT:
pos = min(len(buf), pos + 1)
elif key == curses.KEY_HOME:
pos = 0
elif key == curses.KEY_END:
pos = len(buf)
elif 32 <= key <= 255:
ch = chr(key)
buf = buf[:pos] + ch + buf[pos:]
pos += 1
del win
stdscr.touchwin()
stdscr.refresh()
if result is None:
return None
return result.strip()
def _confirm(app, stdscr, message, width=60):
pw = min(width, app.w - 4)
ph = 4
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
try:
win.addstr(0, 2, " Konfirmasi ", curses.A_BOLD)
except curses.error:
pass
display = message if len(message) <= pw - 4 else message[:pw - 7] + "..."
try:
win.addstr(1, 2, display)
except curses.error:
pass
try:
win.addstr(2, 2, " [Y]es [N]o ")
except curses.error:
pass
win.refresh()
result = False
while True:
key = win.getch()
if key in (ord("y"), ord("Y")):
result = True
break
elif key in (ord("n"), ord("N"), 27):
break
del win
stdscr.touchwin()
stdscr.refresh()
return result
def _run_list_popup(app, stdscr, title, items, footer="", current_idx=0, active_flags=None):
"""Popup daftar generik. Mengembalikan (idx, key).
key: KEY_ENTER / 10/13 (pilih), 27 (tutup), atau kode char aksi (a/d/e/r/dst).
items: list label; gunakan "---" untuk separator.
active_flags: list bool sejajar items untuk memberi gaya A_DIM."""
if not items:
items = ["(kosong)"]
pw = min(60, app.w - 4)
ph = min(len(items) + 4, app.h - 4)
if ph < 5:
return -1, 27
px = (app.w - pw) // 2
py = (app.h - ph) // 2
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
while True:
win.erase()
win.box()
try:
win.addstr(0, 2, f" {title} ", curses.A_BOLD)
except curses.error:
pass
visible_h = ph - 2
total = len(items)
scroll = max(0, min(current_idx - visible_h // 2, total - visible_h))
for i in range(visible_h):
idx = scroll + i
if idx >= total:
break
y = 1 + i
label = items[idx]
if label == "---":
try:
win.addstr(y, 2, "\u2500" * max(0, pw - 4),
curses.color_pair(theme.C_MENU_DISABLED))
except curses.error:
pass
continue
is_cur = (idx == current_idx)
active = True
if active_flags and idx < len(active_flags):
active = bool(active_flags[idx])
if is_cur:
prefix = " \u25b6 " if active else " > "
else:
prefix = " "
if is_cur:
attr = curses.A_REVERSE
elif not active:
attr = curses.A_DIM
else:
attr = curses.A_NORMAL
display = prefix + label
if len(display) > pw - 4:
display = display[:pw - 4]
try:
win.addstr(y, 2, display.ljust(pw - 4), attr)
except curses.error:
pass
if footer:
try:
win.addstr(ph - 1, 2, footer[:pw - 4], curses.A_DIM)
except curses.error:
pass
win.refresh()
key = keycodes.read_key(stdscr, -1)
if key in (curses.KEY_UP,):
current_idx = max(0, current_idx - 1)
elif key in (curses.KEY_DOWN,):
current_idx = min(total - 1, current_idx + 1)
elif key in (27,):
del win
stdscr.touchwin()
stdscr.refresh()
return current_idx, 27
elif key in (curses.KEY_ENTER, 10, 13):
del win
stdscr.touchwin()
stdscr.refresh()
return current_idx, key
elif 32 <= key <= 255:
del win
stdscr.touchwin()
stdscr.refresh()
return current_idx, key
def _first_model_desc(set_id):
models = moop.list_models(None, set_id)
for m in models:
if m["priority"] is not None:
return f"{m['provider']} / {m['model']}"
return None
# ---------------------------------------------------------- select model ---
def model_selector_popup(app, stdscr):
while True:
sets = moop.list_sets(None)
ids = []
items = []
flags = []
current = 0
default_id = moop.get_default_set_id(None, "llm")
for s in sets:
if "llm" not in s["types"]:
continue
ids.append(s["id"])
label = s["name"]
if s["id"] == default_id:
label += " [default]"
first = _first_model_desc(s["id"])
if first:
label += f" ({first})"
items.append(label)
flags.append(s["id"] == default_id)
if s["id"] == default_id:
current = len(items) - 1
if not items:
log(app, "error", "Tidak ada model set tipe 'llm'. Atur via Model > Manage Sets.")
return
idx, key = _run_list_popup(
app, stdscr, "Pilih Model Set (LLM)", items,
footer=" \u2191\u2193 nav \u21b5 select esc close ",
current_idx=current, active_flags=flags,
)
if key == 27:
return
elif key in (10, 13, curses.KEY_ENTER):
app.switch_model_set(ids[idx])
return
# ------------------------------------------------------------ manage sets ---
def sets_manage_popup(app, stdscr):
while True:
sets = moop.list_sets(None)
ids = [s["id"] for s in sets]
items = []
for s in sets:
t = ",".join(s["types"]) if s["types"] else "-"
d = " \u2605" if s["default_for"] else ""
items.append(f"{s['name']} [{t}]{d}")
idx, key = _run_list_popup(
app, stdscr, "Model Sets", items,
footer=" \u2191\u2193 nav \u21b5 detail a add r rename d delete esc close ",
)
if key == 27:
return
elif key == ord("a"):
name = _text_input(app, stdscr, "Nama model set:")
if name:
sid = moop.create_set(None, name)
log(app, "system", f"Model set '{name}' dibuat.")
if sid is not None:
set_detail_popup(app, stdscr, sid)
elif key == ord("r") and ids:
name = _text_input(app, stdscr, "Rename model set:", initial=sets[idx]["name"])
if name:
moop.rename_set(None, ids[idx], name)
log(app, "system", "Model set di-rename.")
elif key == ord("d") and ids:
if _confirm(app, stdscr, f"Hapus model set '{sets[idx]['name']}'?"):
moop.delete_set(None, ids[idx])
log(app, "system", "Model set dihapus.")
elif key in (10, 13, curses.KEY_ENTER) and ids:
set_detail_popup(app, stdscr, ids[idx])
def set_detail_popup(app, stdscr, set_id):
while True:
s = moop.get_set(None, set_id)
if not s:
return
items = []
vals = []
for t in MODEL_TYPES:
if t in s["types"]:
star = " \u2605 default" if t in s["default_for"] else ""
n = len(moop.list_models(None, set_id))
items.append(f"{t}{star} ({n} model)")
vals.append(t)
idx, key = _run_list_popup(
app, stdscr, f"Set: {s['name']}", items,
footer=" \u2191\u2193 \u21b5 models a add type d del type s default r rename esc close ",
)
if key == 27:
return
elif key == ord("a"):
available = [t for t in MODEL_TYPES if t not in s["types"]]
if not available:
log(app, "system", "Semua tipe sudah ada di set ini.")
continue
tidx, tkey = _run_list_popup(
app, stdscr, "Tambah Tipe", list(available),
footer=" \u21b5 pilih esc batal ",
)
if tkey in (10, 13, curses.KEY_ENTER):
tname = available[tidx]
moop.add_type(None, set_id, tname)
log(app, "system", f"Tipe '{tname}' ditambahkan.")
elif key == ord("s") and vals:
moop.set_default(None, vals[idx], set_id)
log(app, "system", f"Set ini menjadi default untuk '{vals[idx]}'.")
elif key == ord("d") and vals:
if _confirm(app, stdscr, f"Hapus tipe '{vals[idx]}' dari set ini?"):
moop.remove_type(None, set_id, vals[idx])
log(app, "system", f"Tipe '{vals[idx]}' dihapus dari set.")
elif key == ord("r"):
newname = _text_input(app, stdscr, "Rename model set:", initial=s["name"])
if newname:
moop.rename_set(None, set_id, newname)
elif key in (10, 13, curses.KEY_ENTER) and vals:
set_models_popup(app, stdscr, set_id, vals[idx])
def _pick_provider(app, stdscr):
apis = moop.list_apis(None)
if not apis:
log(app, "system", "Belum ada provider. Tambah via Model > Manage Providers.")
return None
items = [f"{a['name']} ({a['baseurl']})" for a in apis]
idx, key = _run_list_popup(
app, stdscr, "Pilih Provider", items,
footer=" \u2191\u2193 nav \u21b5 pilih esc batal ",
)
if key in (10, 13, curses.KEY_ENTER):
return apis[idx]["id"]
return None
def _add_model_popup(app, stdscr, set_id):
api_id = _pick_provider(app, stdscr)
if api_id is None:
return
model = _text_input(app, stdscr, "Nama model:")
if not model:
return
pri = _text_input(app, stdscr, "Priority (kosong = disabled):")
moop.add_model(None, set_id, api_id, model, _parse_priority(pri))
log(app, "system", f"Model '{model}' ditambahkan.")
def _edit_model_popup(app, stdscr, m):
model = _text_input(app, stdscr, "Nama model:", initial=m["model"])
if not model:
return
pri = _text_input(
app, stdscr, "Priority (kosong = disabled):",
initial="" if m["priority"] is None else str(m["priority"]),
)
moop.update_model(None, m["model_id"], model, _parse_priority(pri))
log(app, "system", "Model di-update.")
def set_models_popup(app, stdscr, set_id, type_name):
while True:
models = moop.list_models(None, set_id)
ids = [m["model_id"] for m in models]
items = []
for m in models:
pri = str(m["priority"]) if m["priority"] is not None else "off"
items.append(f"{m['provider']} / {m['model']} (pri {pri})")
idx, key = _run_list_popup(
app, stdscr, f"Models ({type_name})", items,
footer=" \u2191\u2193 a add e edit d delete esc close ",
)
if key == 27:
return
elif key == ord("a"):
_add_model_popup(app, stdscr, set_id)
elif key == ord("e") and ids:
_edit_model_popup(app, stdscr, models[idx])
elif key == ord("d") and ids:
if _confirm(app, stdscr, f"Hapus model '{models[idx]['model']}'?"):
moop.delete_model(None, ids[idx])
log(app, "system", "Model dihapus.")
# ---------------------------------------------------------- manage provider ---
def providers_manage_popup(app, stdscr):
while True:
apis = moop.list_apis(None)
ids = [a["id"] for a in apis]
items = [f"{a['name']} ({a['baseurl']}) keys:{a['key_count']}" for a in apis]
idx, key = _run_list_popup(
app, stdscr, "Providers (API)", items,
footer=" \u2191\u2193 \u21b5 keys a add e edit d delete esc close ",
)
if key == 27:
return
elif key == ord("a"):
name = _text_input(app, stdscr, "Nama provider:")
if not name:
continue
baseurl = _text_input(app, stdscr, "Base URL:")
if baseurl:
moop.create_api(None, name, baseurl)
log(app, "system", f"Provider '{name}' ditambahkan.")
elif key == ord("e") and ids:
name = _text_input(app, stdscr, "Nama provider:", initial=apis[idx]["name"])
baseurl = _text_input(app, stdscr, "Base URL:", initial=apis[idx]["baseurl"])
if name and baseurl:
moop.update_api(None, ids[idx], name, baseurl)
log(app, "system", "Provider di-update.")
elif key == ord("d") and ids:
if _confirm(app, stdscr,
f"Hapus provider '{apis[idx]['name']}'?\n(keys & model terkait ikut terhapus)"):
moop.delete_api(None, ids[idx])
log(app, "system", "Provider dihapus.")
elif key in (10, 13, curses.KEY_ENTER) and ids:
provider_detail_popup(app, stdscr, ids[idx])
def provider_detail_popup(app, stdscr, api_id):
api = moop.get_api(None, api_id)
if not api:
return
while True:
keys = moop.list_keys(None, api_id)
ids = [k["id"] for k in keys]
items = []
for k in keys:
pri = str(k["priority"]) if k["priority"] is not None else "off"
items.append(f"{_mask_key(k['key'])} (pri {pri})")
idx, key = _run_list_popup(
app, stdscr, f"API Keys: {api['name']}", items,
footer=" \u2191\u2193 a add e edit d delete esc close ",
)
if key == 27:
return
elif key == ord("a"):
k = _text_input(app, stdscr, "API Key:")
if k:
pri = _text_input(app, stdscr, "Priority (kosong = disabled):")
moop.add_key(None, api_id, k, _parse_priority(pri))
log(app, "system", "API key ditambahkan.")
elif key == ord("e") and ids:
k = _text_input(app, stdscr, "API Key:", initial=keys[idx]["key"])
pri = _text_input(
app, stdscr, "Priority (kosong = disabled):",
initial="" if keys[idx]["priority"] is None else str(keys[idx]["priority"]),
)
if k:
moop.update_key(None, ids[idx], k, _parse_priority(pri))
log(app, "system", "API key di-update.")
elif key == ord("d") and ids:
if _confirm(app, stdscr, "Hapus API key ini?"):
moop.delete_key(None, ids[idx])