Model selector
This commit is contained in:
parent
0fa6fc9db9
commit
6d503c2427
56
config.py
56
config.py
@ -6,7 +6,7 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# ─── YAML Config Loader ────────────────────────────────────────────────────────
|
def _yaml_get(*keys, default=None):
|
||||||
|
|
||||||
_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml"
|
_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml"
|
||||||
_yaml: dict = {}
|
_yaml: dict = {}
|
||||||
@ -14,8 +14,6 @@ if _CONFIG_PATH.is_file():
|
|||||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
_yaml = yaml.safe_load(f) or {}
|
_yaml = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
def _yaml_get(*keys, default=None):
|
|
||||||
"""Navigate nested yaml dict, return default if any key missing."""
|
|
||||||
d = _yaml
|
d = _yaml
|
||||||
for k in keys:
|
for k in keys:
|
||||||
if isinstance(d, dict) and k in d:
|
if isinstance(d, dict) and k in d:
|
||||||
@ -24,26 +22,58 @@ def _yaml_get(*keys, default=None):
|
|||||||
return default
|
return default
|
||||||
return d if d is not None else 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_timeout = int(_yaml_get("llm", "timeout", default=600))
|
||||||
|
|
||||||
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"))
|
|
||||||
|
|
||||||
XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="")
|
XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="")
|
||||||
XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", 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_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")))
|
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()
|
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_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()
|
PERSONA_AGE = os.getenv("PERSONA_AGE", default=_yaml_get("persona", "age", default="")).strip()
|
||||||
|
|||||||
45
config.yaml
45
config.yaml
@ -1,20 +1,55 @@
|
|||||||
|
llm:
|
||||||
|
timeout: 3000 # second
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
max_iterations: 40
|
max_iterations: 40 # step
|
||||||
max_tool_output: 40000
|
max_tool_output: 40000
|
||||||
character: lily # Directory name in agent/characters/<character>/
|
character: hendrik # Directory name in agent/characters/<character>/
|
||||||
|
|
||||||
xmpp:
|
xmpp:
|
||||||
enabled: true
|
enabled: false
|
||||||
muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server"
|
muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server"
|
||||||
nickname: "" # custom MUC nickname (empty = use username)
|
nickname: "" # custom MUC nickname (empty = use username)
|
||||||
selective_response: true # true = only response if mentioned/relevant
|
selective_response: true # true = only response if mentioned/relevant
|
||||||
|
|
||||||
# Humanize Delay (anti-bot detection)
|
# Humanize Delay (anti-bot detection)
|
||||||
delay:
|
delay:
|
||||||
read_min: 1.0 # per second
|
read_min: 1.0 # second
|
||||||
read_max: 2.0 # per second
|
read_max: 2.0 # second
|
||||||
typing_speed: 15.0 # characters per second
|
typing_speed: 15.0 # characters per second
|
||||||
typing_max: 10.0 # max typing delay limit 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:
|
rag:
|
||||||
persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local)
|
persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local)
|
||||||
|
|||||||
2
hendrik
2
hendrik
@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/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
|
# Set HENDRIK_DIR env var to override, or update the default below
|
||||||
DEFAULT_DIR="/opt/hendrik"
|
DEFAULT_DIR="/opt/hendrik"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import config
|
||||||
from scripts import ntro
|
from scripts import ntro
|
||||||
|
|
||||||
WELCOME_ART = """
|
WELCOME_ART = """
|
||||||
@ -39,6 +40,12 @@ def submit(app, stdscr):
|
|||||||
return
|
return
|
||||||
|
|
||||||
log(app, "user", query)
|
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_buffer = [""]
|
||||||
app.input_line = 0
|
app.input_line = 0
|
||||||
app.input_col = 0
|
app.input_col = 0
|
||||||
|
|||||||
@ -29,6 +29,11 @@ class HendrikTUI:
|
|||||||
self.agent_thread: threading.Thread | None = None
|
self.agent_thread: threading.Thread | None = None
|
||||||
self.agent_done = threading.Event()
|
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):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
curses.wrapper(self._main)
|
curses.wrapper(self._main)
|
||||||
|
|||||||
109
tui/input.py
109
tui/input.py
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import curses
|
import curses
|
||||||
import os
|
import os
|
||||||
|
import config
|
||||||
from .agent import submit, log
|
from .agent import submit, log
|
||||||
|
|
||||||
|
|
||||||
@ -55,6 +56,9 @@ def handle_key(app, stdscr, key):
|
|||||||
workspace_popup(app, stdscr)
|
workspace_popup(app, stdscr)
|
||||||
elif key == 12: # Ctrl+L → clear chat log
|
elif key == 12: # Ctrl+L → clear chat log
|
||||||
app.log.clear()
|
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 --
|
# -- Enter: split logical line at cursor position --
|
||||||
elif key in (curses.KEY_ENTER, 10, 13):
|
elif key in (curses.KEY_ENTER, 10, 13):
|
||||||
@ -154,3 +158,108 @@ def workspace_popup(app, stdscr):
|
|||||||
|
|
||||||
stdscr.touchwin()
|
stdscr.touchwin()
|
||||||
stdscr.refresh()
|
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()
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import curses
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import textwrap
|
import textwrap
|
||||||
|
import config
|
||||||
|
|
||||||
# -- Color pair IDs (id 1-9, id 0 = default curses) --
|
# -- Color pair IDs (id 1-9, id 0 = default curses) --
|
||||||
C_HEADER = 1 # header bar: biru
|
C_HEADER = 1 # header bar: biru
|
||||||
@ -126,6 +127,17 @@ def draw_chat(app, stdscr):
|
|||||||
_add_blank()
|
_add_blank()
|
||||||
|
|
||||||
if role == "user":
|
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']}) "
|
label = f" You ({item['time']}) "
|
||||||
_add_row([(C_USER, label)])
|
_add_row([(C_USER, label)])
|
||||||
_wrap_render(text, indent=1, color=C_INPUT)
|
_wrap_render(text, indent=1, color=C_INPUT)
|
||||||
@ -326,7 +338,7 @@ def draw_status(app, stdscr):
|
|||||||
y = h - 9
|
y = h - 9
|
||||||
ws = os.getcwd()
|
ws = os.getcwd()
|
||||||
mode = " PROCESSING " if app.processing else " READY "
|
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
|
max_ws = w - len(mode) - len(hints) - 4
|
||||||
if len(ws) > max_ws:
|
if len(ws) > max_ws:
|
||||||
ws = ".." + ws[-(max_ws - 2):]
|
ws = ".." + ws[-(max_ws - 2):]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user