From 6d503c24277556359df30e4548bcf48b5d6f3af5 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 16 Jun 2026 14:59:40 +0700 Subject: [PATCH] Model selector --- config.py | 68 ++++++++++++++++++++++--------- config.yaml | 45 ++++++++++++++++++--- hendrik | 2 +- tui/agent.py | 7 ++++ tui/app.py | 5 +++ tui/input.py | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++ tui/render.py | 14 ++++++- 7 files changed, 224 insertions(+), 26 deletions(-) diff --git a/config.py b/config.py index 0b4a19d..a53ef35 100644 --- a/config.py +++ b/config.py @@ -6,16 +6,14 @@ from dotenv import load_dotenv load_dotenv() -# ─── YAML Config Loader ──────────────────────────────────────────────────────── - -_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml" -_yaml: dict = {} -if _CONFIG_PATH.is_file(): - with open(_CONFIG_PATH, "r", encoding="utf-8") as f: - _yaml = yaml.safe_load(f) or {} - def _yaml_get(*keys, default=None): - """Navigate nested yaml dict, return default if any key missing.""" + + _CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml" + _yaml: dict = {} + if _CONFIG_PATH.is_file(): + with open(_CONFIG_PATH, "r", encoding="utf-8") as f: + _yaml = yaml.safe_load(f) or {} + d = _yaml for k in keys: if isinstance(d, dict) and k in d: @@ -24,26 +22,58 @@ def _yaml_get(*keys, default=None): return default return d if d is not None else default +# Credential / Secret (only from .env) +llm_baseurl = os.getenv("LLM_BASE_URL", default="") +llm_model = os.getenv("LLM_MODEL", default="") +llm_api_key = os.getenv("LLM_API_KEY", default="") -# ─── Credential / Secret (hanya dari .env) ───────────────────────────────────── - -llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1") -llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b") -llm_api_key = os.getenv("LLM_API_KEY", default="ollama") -llm_timeout = int(os.getenv("LLM_TIMEOUT", default="600")) +llm_timeout = int(_yaml_get("llm", "timeout", default=600)) XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="") XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="") +MODELS_ITEMS: list[dict] = [] +_models_config = _yaml_get("models", default={}) +_items = _models_config.get("items", []) +if isinstance(_items, list): + for entry in _items: + if not isinstance(entry, dict): + continue + model = entry.get("model", "") + provider = entry.get("provider", "") + base_url = entry.get("base_url", "").rstrip("/") + api_key = entry.get("key", "") or llm_api_key + MODELS_ITEMS.append({ + "model": model, + "provider": provider, + "base_url": base_url, + "api_key": api_key, + "default": entry.get("default", False), + }) -# ─── Agent Config (YAML, bisa di-override dari .env) ──────────────────────────── +def resolve_provider(base_url: str, model: str) -> str | None: + """Cari nama provider yg cocok dengan (base_url, model) dari MODELS_ITEMS.""" + base_url = base_url.rstrip("/") + for item in MODELS_ITEMS: + if item["base_url"] == base_url and item["model"] == model: + return item["provider"] + return None + +_has_match = any( + item["model"] == llm_model and item["base_url"] == llm_baseurl.rstrip("/") + for item in MODELS_ITEMS +) +if not _has_match: + for item in MODELS_ITEMS: + if item.get("default"): + llm_model = item["model"] + llm_baseurl = item["base_url"] + llm_api_key = item["api_key"] + break AGENT_MAX_ITERATIONS = int(os.getenv("AGENT_MAX_ITERATIONS", default=_yaml_get("agent", "max_iterations", default="30"))) AGENT_MAX_TOOL_OUTPUT = int(os.getenv("AGENT_MAX_TOOL_OUTPUT", default=_yaml_get("agent", "max_tool_output", default="40000"))) - -# ─── Persona / Mode (YAML, bisa di-override dari .env) ────────────────────────── - AGENT_SKILL = os.getenv("AGENT_SKILL", default="programmer").strip().lower() PERSONA_NAME = os.getenv("PERSONA_NAME", default=_yaml_get("persona", "name", default="Hendrik")).strip() or "Hendrik" PERSONA_AGE = os.getenv("PERSONA_AGE", default=_yaml_get("persona", "age", default="")).strip() diff --git a/config.yaml b/config.yaml index 901b4ad..9b71bdc 100644 --- a/config.yaml +++ b/config.yaml @@ -1,20 +1,55 @@ +llm: + timeout: 3000 # second + agent: - max_iterations: 40 + max_iterations: 40 # step max_tool_output: 40000 - character: lily # Directory name in agent/characters// + character: hendrik # Directory name in agent/characters// xmpp: - enabled: true + enabled: false muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server" nickname: "" # custom MUC nickname (empty = use username) selective_response: true # true = only response if mentioned/relevant # Humanize Delay (anti-bot detection) delay: - read_min: 1.0 # per second - read_max: 2.0 # per second + read_min: 1.0 # second + read_max: 2.0 # second typing_speed: 15.0 # characters per second typing_max: 10.0 # max typing delay limit per second +models: + items: + - model: "granite4.1:8b" + provider: "Ollama Local" + base_url: "http://localhost:11434/v1" + key: "ollama" + - model: "granite4.1:8b" + provider: "Transformers API Local" + base_url: "http://localhost:12345/v1" + key: "sk-not-needed" + - model: "ministral-3:14b-cloud" + provider: "Ollama Cloud" + base_url: "https://ollama.com/v1" + key: "" + - model: "gemma4:31b-cloud" + provider: "Ollama Cloud" + base_url: "https://ollama.com/v1" + key: "" + default: true + - model: "openrouter/owl-alpha" + provider: "OpenRouter" + base_url: "https://openrouter.ai/api/v1" + key: "" + - model: "nex-agi/nex-n2-pro:free" + provider: "OpenRouter" + base_url: "https://openrouter.ai/api/v1" + key: "" + - model: "z-ai/glm-5" + provider: "OpenRouter" + base_url: "https://openrouter.ai/api/v1" + key: "" + rag: persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local) diff --git a/hendrik b/hendrik index 7181474..32c3118 100755 --- a/hendrik +++ b/hendrik @@ -1,5 +1,5 @@ #!/bin/bash -# hendrik — wrapper to run the TUI agent from anywhere +# wrapper to run the TUI agent from anywhere # Set HENDRIK_DIR env var to override, or update the default below DEFAULT_DIR="/opt/hendrik" diff --git a/tui/agent.py b/tui/agent.py index fb85cd5..f726bcc 100644 --- a/tui/agent.py +++ b/tui/agent.py @@ -1,6 +1,7 @@ import json import threading from datetime import datetime +import config from scripts import ntro WELCOME_ART = """ @@ -39,6 +40,12 @@ def submit(app, stdscr): return log(app, "user", query) + model_info = ( + config.resolve_provider(app.llm.base_url, app.llm.model), + app.llm.model + ) + if app.log: + app.log[-1]["model_info"] = model_info app.input_buffer = [""] app.input_line = 0 app.input_col = 0 diff --git a/tui/app.py b/tui/app.py index 1c167dd..80b4843 100644 --- a/tui/app.py +++ b/tui/app.py @@ -29,6 +29,11 @@ class HendrikTUI: self.agent_thread: threading.Thread | None = None self.agent_done = threading.Event() + def switch_model(self, item: dict): + self.llm.base_url = item["base_url"] + self.llm.model = item["model"] + self.llm.api_key = item["api_key"] + def run(self): try: curses.wrapper(self._main) diff --git a/tui/input.py b/tui/input.py index f2a3dd1..4304fc8 100644 --- a/tui/input.py +++ b/tui/input.py @@ -4,6 +4,7 @@ import curses import os +import config from .agent import submit, log @@ -55,6 +56,9 @@ def handle_key(app, stdscr, key): workspace_popup(app, stdscr) elif key == 12: # Ctrl+L → clear chat log app.log.clear() + elif key == 5: # Ctrl+E → model selector popup + if not processing: + model_selector_popup(app, stdscr) # -- Enter: split logical line at cursor position -- elif key in (curses.KEY_ENTER, 10, 13): @@ -154,3 +158,108 @@ def workspace_popup(app, stdscr): stdscr.touchwin() stdscr.refresh() + +def model_selector_popup(app, stdscr): + current_base = app.llm.base_url.rstrip("/") + current_model = app.llm.model + + # Group by provider + providers: list[tuple[str, list[dict]]] = [] + seen = {} + for item in config.MODELS_ITEMS: + p = item["provider"] + if p not in seen: + seen[p] = [] + providers.append((p, seen[p])) + seen[p].append(item) + + items = [] # (type, data, label) + selectable = [] # indices into items for model entries + current_idx = 0 + + for pname, plist in providers: + items.append(("header", None, pname)) + for entry in plist: + idx = len(items) + items.append(("model", entry, entry["model"])) + selectable.append(idx) + if entry["base_url"] == current_base and entry["model"] == current_model: + current_idx = len(selectable) - 1 + + if not selectable: + return + + pw = min(50, app.w - 4) + ph = min(len(items) + 4, app.h - 4) + px = (app.w - pw) // 2 + py = (app.h - ph) // 2 + if ph < 6: + return + + win = curses.newwin(ph, pw, py, px) + win.keypad(True) + + while True: + win.erase() + win.box() + win.addstr(0, 2, " Pilih Model (Ctrl+E) ", curses.A_BOLD) + + visible_h = ph - 2 + total = len(items) + scroll = max(0, min(selectable[current_idx] - visible_h // 2, total - visible_h)) + + for i in range(visible_h): + idx = scroll + i + if idx >= total: + break + typ, data, label = items[idx] + y = 1 + i + if typ == "header": + win.addstr(y, 2, f" {label} ", curses.A_BOLD | curses.A_UNDERLINE) + else: + is_cur = (idx == selectable[current_idx]) + is_active = (data["base_url"] == current_base and data["model"] == current_model) + + if is_cur: + prefix = " \u25b6 " if is_active else " > " + elif is_active: + prefix = " \u2192 " + else: + prefix = " " + + display = prefix + label + if len(display) > pw - 4: + display = display[:pw - 4] + attr = curses.A_REVERSE if is_cur else curses.A_NORMAL + win.addstr(y, 2, display.ljust(pw - 4), attr) + + footer = " \u2191\u2193 nav \u21b5 select esc/q close " + win.addstr(ph - 1, 2, footer[:pw - 4], curses.A_DIM) + + try: + target_y = selectable[current_idx] - scroll + 1 + if 0 <= target_y < ph - 1: + win.move(target_y, 4) + except curses.error: + pass + + win.refresh() + key = win.getch() + + if key in (27, ord("q"), ord("Q")): + break + elif key in (curses.KEY_ENTER, 10, 13): + sel_item = items[selectable[current_idx]] + if sel_item[0] == "model": + app.switch_model(sel_item[1]) + break + elif key == curses.KEY_UP: + if current_idx > 0: + current_idx -= 1 + elif key == curses.KEY_DOWN: + if current_idx < len(selectable) - 1: + current_idx += 1 + + del win + stdscr.touchwin() + stdscr.refresh() diff --git a/tui/render.py b/tui/render.py index a518713..2438a42 100644 --- a/tui/render.py +++ b/tui/render.py @@ -6,6 +6,7 @@ import curses import json import os import textwrap +import config # -- Color pair IDs (id 1-9, id 0 = default curses) -- C_HEADER = 1 # header bar: biru @@ -126,6 +127,17 @@ def draw_chat(app, stdscr): _add_blank() if role == "user": + model_info = item.get("model_info", None) + if model_info: + p_name, m_name = model_info + else: + p_name = config.resolve_provider(app.llm.base_url, app.llm.model) + m_name = app.llm.model + if p_name: + info_line = f" {p_name} - {m_name} " + else: + info_line = f" {m_name} " + _add_row([(C_SYSTEM, info_line)]) label = f" You ({item['time']}) " _add_row([(C_USER, label)]) _wrap_render(text, indent=1, color=C_INPUT) @@ -326,7 +338,7 @@ def draw_status(app, stdscr): y = h - 9 ws = os.getcwd() mode = " PROCESSING " if app.processing else " READY " - hints = " ^D:send ^W:workspace ^C:exit " + hints = " ^D:send ^E:model ^W:workspace ^C:exit " max_ws = w - len(mode) - len(hints) - 4 if len(ws) > max_ws: ws = ".." + ws[-(max_ws - 2):]