import json import threading from datetime import datetime from lib import ntro, agent_loop def _add_msg(app, role, content, **kwargs): msg = {"role": role, "content": content} msg.update(kwargs) app.messages.append(msg) if app.current_session: app.session_mgr.add_message( app.current_session.doc_id, role, content, **kwargs ) def _persist_msg(app, message): """Persist satu pesan yang baru ditulis engine ke store TUI. Dipasang sebagai SessionStore.on_append: engine menulis ke app.messages, kita hanya menyalinnya ke session DB (TinyDB).""" if not app.current_session: return extra = {k: v for k, v in message.items() if k not in ("role", "content")} app.session_mgr.add_message( app.current_session.doc_id, message["role"], message["content"], **extra ) WELCOME_ART = """ ,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--. | '--' || .---'| ,'.| || .-. \\ | .--. '| || .' / | .--. || `--, | |' ' || | \\ :| '--'.'| || . ' | | | || `---.| | ` || '--' /| |\\ \\ | || |\\ \\ `--' `--'`------'`--' `--'`-------' `--' '--'`--'`--' '--' """ """ __ __ _______ __ _ ______ ______ ___ ___ _ | | | || || | | || | | _ | | | | | | | | |_| || ___|| |_| || _ || | || | | | |_| | | || |___ | || | | || |_||_ | | | _| | || ___|| _ || |_| || __ || | | |_ | _ || |___ | | | || || | | || | | _ | |__| |__||_______||_| |__||______| |___| |_||___| |___| |_| """ """ ╔══════════════════════════════════════════╗ ║ ║ ║ /\\_/\\ ║ ║ ( o.o ) HENDRIK ║ ║ > ^ < ║ ║ ( ) AI Agent ║ ║ (___) ║ ║ ║ ╚══════════════════════════════════════════╝ """ 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) return 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 class CursesObserver(agent_loop.AgentObserver): """Terjemahkan hook engine ke state tampilan TUI (app.log, scroll, ntro). Semua yang tadinya hardcoded di dalam _agent_loop pindah ke sini, sehingga lib.agent_loop tidak lagi mengenal curses. Interface lain cukup membuat observer sendiri tanpa menyentuh loop. """ wants_streaming = True # Konten final dengan awalan ini datang dari LLMClient sebagai kegagalan, # bukan jawaban normal — tampilkan sebagai error, bukan balasan AI. _ERROR_PREFIXES = ("Error:", "HTTP Error:") def __init__(self, app): self.app = app self.pair = None self._buf = [] self._placeholder = None self._stamp = None self._stamp_step = None # ------------------------------------------------------------- lifecycle --- def on_run_start(self): self._stamp = ntro.start() def on_run_end(self): ntro.end(self._stamp) self.app.agent_done.set() def on_step_start(self, step): self._stamp_step = ntro.start() self._buf = [] self._placeholder = None log(self.app, "system", f" step {step + 1} \u2014 Thinking...") # Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut # terjaga dan id()-nya tidak dipakai ulang saat entri itu di-pop. self._log_prev = list(self.app.log) self._follow() def on_step_end(self, step): ntro.end(self._stamp_step) def on_response(self, model_info, warning): self.pair = _pair(model_info) self._pop_thinking() if warning: log(self.app, "system", f" {warning}") # --------------------------------------------------------------- stream --- def on_stream_chunk(self, chunk): if self._placeholder is None: # Placeholder dibuat di chunk pertama; isinya diperbarui real-time. self._placeholder = log(self.app, "ai", "") self._buf.append(chunk) self._placeholder["text"] = "".join(self._buf) self._follow() # ----------------------------------------------------------------- tools --- def on_tool_calls(self, content, names): # Narasi assistant sebelum tools; placeholder streaming dibuang karena # konten yang sama akan di-log utuh di sini. self._drop_placeholder() if content and content.strip(): log(self.app, "ai", content, model_info=self.pair) _tag_ai_logs(self.app, self._log_prev, self.pair) def on_tool_call(self, name, arguments): log(self.app, "tool_call", json.dumps({ "name": name, "arguments": arguments, }), model_info=self.pair) self._follow() # ---------------------------------------------------------------- final --- def on_final(self, result): content = result.content if isinstance(content, str) and content.startswith(self._ERROR_PREFIXES): self._drop_placeholder() log(self.app, "error", content) log(self.app, "sep", "") return if result.is_multimodal: log(self.app, "ai", "[Image generated]", model_info=self.pair) elif content is None: # Respons kosong: tidak ada yang ditampilkan selain pemisah. pass elif self._placeholder is None and content: # Tidak ada chunk streaming sama sekali (provider tidak mengirim # delta) — pastikan teks tetap terlihat. log(self.app, "ai", content, model_info=self.pair) # placeholder streaming tidak perlu diisi ulang: isinya sudah teks final _tag_ai_logs(self.app, self._log_prev, self.pair) log(self.app, "sep", "") def on_max_iterations(self, max_iterations=None): log(self.app, "error", "Max iterations reached without final answer.") # ----------------------------------------------------------------- utils --- def _follow(self): if self.app.scroll_follow: self.app.scroll = 999999 def _pop_thinking(self): for i in range(len(self.app.log) - 1, -1, -1): if self.app.log[i].get("role") == "system" and "Thinking" in self.app.log[i].get("text", ""): self.app.log.pop(i) break def _drop_placeholder(self): if self._placeholder is None: return entry, self._placeholder = self._placeholder, None for i in range(len(self.app.log) - 1, -1, -1): if self.app.log[i] is entry: self.app.log.pop(i) break def _run_agent(app): """Entry point thread agent: satu-satunya tempat TUI memanggil engine.""" store = agent_loop.SessionStore( app.messages, on_append=lambda msg: _persist_msg(app, msg), keep_model_info=True, ) agent_loop.run_agent_loop( store, app.llm, app.TOOLS, app.TOOL_HANDLERS, app.agent_max_iterations, observer=CursesObserver(app), tool_reminder=None, ) def submit(app, stdscr): query = "\n".join(app.input_buffer).strip() if not query: return log(app, "user", query) app.input_buffer = [""] app.input_line = 0 app.input_col = 0 app.scroll = 999999 app.processing = True # Status langsung pindah ke PROCESSING sebelum kerja berat (persistensi # session / LLM) yang memblok UI thread. Lazy import untuk menghindari # circular import (agent -> render -> menubar -> actions -> agent). from .render import draw draw(app, stdscr) if app.current_session is None: app.current_session = app.session_mgr.create( f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}", app._model_info(), ) _add_msg(app, "user", query) app.agent_done.clear() app.agent_thread = threading.Thread( target=_run_agent, args=(app,), daemon=True, ) app.agent_thread.start()