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/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