diff --git a/config.default.yaml b/config.default.yaml index b70c25a..702ee42 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -28,6 +28,8 @@ asr: # Speech-to-Text (push-to-talk TUI). Model dikelola via moop tipe 'asr'. max_seconds : 60 # auto-stop pengaman transcribe_timeout: 60 # detik timeout request ASR keep_recordings : true # false = hapus file setelah ditranskrip + auto_submit : false # true = hasil transkrip langsung dikirim + # false = hanya mengisi form (Ctrl+Enter) session: # Chat session db_path: "~/.config/hendrik/sessions.json" diff --git a/config.py b/config.py index 5e0845f..d281d49 100644 --- a/config.py +++ b/config.py @@ -61,6 +61,10 @@ ASR_KEEP_RECORDINGS = _as_bool(_yaml_get("asr", "keep_recordings", default=True) # Tombol push-to-talk: "f1".."f12" atau "ctrl-". Default F9. ASR_RECORD_KEY = str(_yaml_get("asr", "record_key", default="f9")).strip().lower() +# true = hasil transkripsi langsung dikirim sebagai pesan, +# false = hasil hanya disisipkan ke form input (user kirim sendiri via Ctrl+Enter). +ASR_AUTO_SUBMIT = _as_bool(_yaml_get("asr", "auto_submit", default=False), default=False) + XMPP_USERNAME = _yaml_get("xmpp", "username", default="") XMPP_PASSWORD = _yaml_get("xmpp", "password", default="") diff --git a/interfaces/tui/agent.py b/interfaces/tui/agent.py index da86ac0..87169a9 100644 --- a/interfaces/tui/agent.py +++ b/interfaces/tui/agent.py @@ -2,7 +2,6 @@ import json import threading from datetime import datetime import config -from lib import moop from lib import ntro, agent_loop from tools.vision import ImagePayload @@ -45,12 +44,39 @@ WELCOME_ART = """ ╚══════════════════════════════════════════╝ """ -def log(app, role, text): - app.log.append({ +def log(app, role, text, model_info=None): + entry = { "role": role, "text": text, "time": datetime.now().strftime("%H:%M"), - }) + } + if model_info: + # Display pair: (provider, model) + entry["model_info"] = model_info + app.log.append(entry) + + +def _pair(model_info): + """(provider, model) untuk display; None jika info tidak ada.""" + if not model_info: + return None + return (model_info.get("provider"), model_info.get("model")) + + +def _tag_ai_logs(app, prev_entries, pair): + """Tempelkan info model yang benar-benar dipakai ke entri log AI yang baru + dibuat pada langkah ini. + + Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah + llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries` + menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python. + """ + if not pair: + return + seen = {id(e) for e in prev_entries} + for entry in app.log: + if entry.get("role") == "ai" and id(entry) not in seen: + entry["model_info"] = pair def submit(app, stdscr): @@ -59,12 +85,6 @@ def submit(app, stdscr): return log(app, "user", query) - model_info = ( - moop.resolve_provider(None, 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 @@ -83,7 +103,7 @@ def submit(app, stdscr): app._model_info(), ) - _add_msg(app, "user", query, model_info=app._model_info()) + _add_msg(app, "user", query) app.agent_done.clear() app.agent_thread = threading.Thread( @@ -128,8 +148,15 @@ def _agent_loop(app): skill = config.AGENT_SKILLS tool_reminder = None + log_prev = list(app.log) response = app.llm.chat(app.messages, tools=app.TOOLS, on_stream_chunk=on_stream_chunk, tool_reminder=tool_reminder) + # Model final: diambil dari response, bukan dari state llm saat ini, + # supaya auto-switch/rotasi key/fallback ikut tercatat dengan benar. + used = getattr(response, "model_info", None) or {} + pair = _pair(used) + akwargs = {"model_info": used} if used else {} + # Hapus "Thinking..." log for i in range(len(app.log) - 1, -1, -1): if app.log[i].get('role') == 'system' and 'Thinking' in app.log[i].get('text', ''): @@ -177,11 +204,12 @@ def _agent_loop(app): if placeholder_marker in text or text == "" or text == "...": app.log.pop(i) - _add_msg(app, "assistant", response.content, tool_calls=response.tool_calls) + _add_msg(app, "assistant", response.content, tool_calls=response.tool_calls, **akwargs) # Log content AI dulu (penjelasan sebelum tools) if response.content and response.content.strip(): - log(app, "ai", response.content) + log(app, "ai", response.content, model_info=pair) + _tag_ai_logs(app, log_prev, pair) # Log dan execute tool_calls for tc in response.tool_calls: @@ -190,7 +218,7 @@ def _agent_loop(app): log(app, "tool_call", json.dumps({ "name": tname, "arguments": targs, - })) + }), model_info=pair) if app.scroll_follow: app.scroll = 999999 result = agent_loop.execute_tool(tc, app.TOOL_HANDLERS) @@ -205,8 +233,9 @@ def _agent_loop(app): has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0) if has_content: if isinstance(response.content, list): - log(app, "ai", "[Image generated]") - _add_msg(app, "assistant", response.content) + log(app, "ai", "[Image generated]", model_info=pair) + _add_msg(app, "assistant", response.content, **akwargs) + _tag_ai_logs(app, log_prev, pair) log(app, "sep", "") ntro.end(stamp) app.agent_done.set() diff --git a/interfaces/tui/app.py b/interfaces/tui/app.py index bf8c2e3..f29f702 100644 --- a/interfaces/tui/app.py +++ b/interfaces/tui/app.py @@ -209,22 +209,23 @@ class HendrikTUI: if i > 0: log(self, "sep", "") log(self, "user", msg["content"]) - mi = msg.get("model_info") - if mi: - self.log[-1]["model_info"] = (mi.get("provider"), mi.get("model")) elif msg["role"] == "assistant": + mi = msg.get("model_info") + pair = (mi.get("provider"), mi.get("model")) if mi else None # Log tool calls jika ada if msg.get("tool_calls"): for tc in msg["tool_calls"]: log(self, "tool_call", json.dumps({ "name": tc["function"]["name"], "arguments": tc["function"]["arguments"], - })) + }), model_info=pair) # Log AI content jika ada dan ini adalah response final next_role = session.messages[i + 1]["role"] if i + 1 < len(session.messages) else None if next_role in ("user", None): if msg.get("content"): log(self, "ai", msg["content"]) + if pair: + self.log[-1]["model_info"] = pair elif msg["role"] == "tool": # Tool result - tidak perlu di-log secara eksplisit pass diff --git a/interfaces/tui/render.py b/interfaces/tui/render.py index aff6627..c663e79 100644 --- a/interfaces/tui/render.py +++ b/interfaces/tui/render.py @@ -55,6 +55,19 @@ def draw_header(app, stdscr): draw_menubar(app, stdscr) +def _model_info_line(item): + """Baris " Provider - Model " dari entri log; "" jika info tidak ada. + + Dipakai bersama oleh blok `ai` dan `tool_call` agar tampilan konsisten.""" + model_info = item.get("model_info") + if not model_info: + return "" + p_name, m_name = model_info + if p_name: + return f" {p_name} - {m_name} " + return f" {m_name} " if m_name else "" + + def draw_chat(app, stdscr): # Area chat — dari baris 2 sampai baris (h - 11). # Bisa di-scroll dengan Page Up / Page Down. @@ -136,20 +149,13 @@ 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 - if p_name: - info_line = f" {p_name} - {m_name} " - else: - info_line = f" {m_name} " - else: - info_line = " Unknown - Model info not found " - _add_row([(C_USER, info_line, False)]) - label = f" You ({item['time']}) " - _add_row([(C_USER, label)]) + _add_row([(C_USER, f" You ({item['time']}) ")]) _wrap_render(text, indent=1, color=C_INPUT, bold=False) elif role == "ai": + info_line = _model_info_line(item) + if info_line: + _add_row([(C_AI, info_line, False)]) + label = f" {app.character_name} ({item['time']}) " _add_row([(C_AI, label)]) _wrap_render(text, indent=1, color=C_INPUT, bold=False) @@ -165,6 +171,7 @@ def draw_chat(app, stdscr): elif role == "tool_call": # Format: # (blank line) + # Ollama Cloud - gemma4:31b-cloud # Hendrik run_bash (HH:MM) ← "Hendrik" hijau, "run_bash (HH:MM)" kuning # { ← arguments indent 1 spasi, kuning # "command": "ls -la" @@ -173,6 +180,9 @@ def draw_chat(app, stdscr): # Blank line di atas (kecuali sebelumnya sudah blank dari role lain) if idx > 0 and app.log[idx - 1]["role"] not in ("sep", "welcome"): _add_blank() + info_line = _model_info_line(item) + if info_line: + _add_row([(C_AI, info_line, False)]) try: tc = json.loads(text) tname = tc["name"] diff --git a/interfaces/tui/talk.py b/interfaces/tui/talk.py index 8594708..a4875da 100644 --- a/interfaces/tui/talk.py +++ b/interfaces/tui/talk.py @@ -2,9 +2,13 @@ # # Alur: # * F9 (configurable) → mulai rekam (ffmpeg subprocess, non-blocking). -# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil DISISIPKAN -# ke form input pada posisi kursor, TIDAK submit. +# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil +# disisipkan ke form input pada posisi kursor, +# TIDAK submit (default). # * Backspace / Esc → batal: rekaman dibuang / hasil transkrip diabaikan. +# * config.asr.auto_submit: true → hasil transkrip langsung dikirim sebagai +# pesan (setara Ctrl+Enter). Jika form sudah terisi +# saat transkrip selesai, hasil tetap disisipkan. # # State disimpan di app: # app.talk_recorder — instance Recorder saat merekam, else None @@ -18,7 +22,7 @@ import threading import config from tools import asr as asr_mod -from .agent import log +from .agent import log, submit def _shortcut_label(): @@ -209,7 +213,8 @@ def shutdown(app): def finish_talk(app, stdscr): - """Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil.""" + """Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil (atau + langsung submit bila config.asr.auto_submit aktif).""" res = getattr(app, "_talk_result", None) app._talk_result = None if getattr(app, "talk_cancelled", False): @@ -222,6 +227,27 @@ def finish_talk(app, stdscr): if not text: log(app, "system", " Tidak ada ucapan terdeteksi.") return + if config.ASR_AUTO_SUBMIT: + # Auto-submit: taruh teks di buffer lalu kirim (submit() yang + # membersihkan buffer). Jika user sudah mengetik sesuatu sebelum + # transkrip selesai, teks tidak ditimpa — disisipkan saja. + if not "\n".join(app.input_buffer).strip(): + app.input_buffer = text.split("\n") + app.input_line = len(app.input_buffer) - 1 + app.input_col = len(app.input_buffer[-1]) + submit(app, stdscr) + log(app, "system", + f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] " + "teks otomatis dikirim") + else: + insert_text(app, text) + log(app, "system", + f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] " + "form sedang terisi \u2014 hasil disisipkan, tidak auto-submit " + "(Ctrl+Enter untuk kirim)") + if app.scroll_follow: + app.scroll = 999999 + return if insert_text(app, text): log(app, "system", f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] " diff --git a/services/llm_client.py b/services/llm_client.py index c32c60e..87f25b9 100644 --- a/services/llm_client.py +++ b/services/llm_client.py @@ -1,6 +1,6 @@ import json import socket -from lib import gadget +from lib import gadget, moop import urllib.request import urllib.error @@ -30,11 +30,14 @@ class LLMClient: self._chain_index = 0 class Message: - def __init__(self, msg): + def __init__(self, msg, model_info=None): raw_content = msg.get('content', '') # Ambil konten mentah self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip dari content self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls self.warning = None + # Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda + # dengan model awal permintaan karena auto-switch/fallback. + self.model_info = model_info or {} # ------------------------------------------------------------ config --- @@ -86,6 +89,23 @@ class LLMClient: self.model = cand.get("model", "") self.api_key = cand.get("api_key", "") + def _model_info(self, cand=None): + """Info model yang dipakai untuk satu permintaan. + + Dari kandidat chain jika ada (provider sudah dibawa moop), jika tidak + (klien non-chain) pakai state aktif dan cari nama provider-nya.""" + if cand: + return { + "provider": cand.get("provider"), + "base_url": cand.get("base_url", ""), + "model": cand.get("model", ""), + } + return { + "provider": moop.resolve_provider(None, self.base_url, self.model), + "base_url": self.base_url, + "model": self.model, + } + # ----------------------------------------------------- chain selection --- def _pick_chain_type(self, messages): @@ -108,7 +128,7 @@ class LLMClient: def chat(self, messages, tools=None, on_stream_chunk=None, disable_reasoning=False, tool_reminder=None): if not self._chains: # kalau bukan chain - return self._chat_once( + result = self._chat_once( self.base_url, self.model, self.api_key, messages, tools=tools, @@ -116,6 +136,8 @@ class LLMClient: disable_reasoning=disable_reasoning, tool_reminder=tool_reminder, ) + result.model_info = self._model_info() + return result chain_type = self._pick_chain_type(messages) if chain_type != self._chain_key: @@ -176,6 +198,9 @@ class LLMClient: disable_reasoning=disable_reasoning, tool_reminder=tool_reminder, ) self._chain_index = idx + # Catat model yang benar-benar menjawab (bukan model awal + # permintaan) supaya pemanggil bisa menampilkan data yang jujur. + result.model_info = self._model_info(cand) return result except _LLMFail as e: last = e