267 lines
9.2 KiB
Python
267 lines
9.2 KiB
Python
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()
|