334 lines
12 KiB
Python
334 lines
12 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
|
|
)
|
|
|
|
|
|
def _truncate_msg(app, count):
|
|
"""Buang `count` pesan terakhir dari DB session (dipanggil saat turn
|
|
dibatalkan), supaya riwayat tersimpan identik dengan app.messages."""
|
|
if not app.current_session or count <= 0:
|
|
return
|
|
app.session_mgr.remove_last_messages(app.current_session.doc_id, count)
|
|
|
|
WELCOME_ART = """
|
|
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
|
|
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
|
|
| .--. || `--, | |' ' || | \\ :| '--'.'| || . '
|
|
| | | || `---.| | ` || '--' /| |\\ \\ | || |\\ \\
|
|
`--' `--'`------'`--' `--'`-------' `--' '--'`--'`--' '--'
|
|
|
|
"""
|
|
"""
|
|
__ __ _______ __ _ ______ ______ ___ ___ _
|
|
| | | || || | | || | | _ | | | | | | |
|
|
| |_| || ___|| |_| || _ || | || | | | |_| |
|
|
| || |___ | || | | || |_||_ | | | _|
|
|
| || ___|| _ || |_| || __ || | | |_
|
|
| _ || |___ | | | || || | | || | | _ |
|
|
|__| |__||_______||_| |__||______| |___| |_||___| |___| |_|
|
|
"""
|
|
"""
|
|
╔══════════════════════════════════════════╗
|
|
║ ║
|
|
║ /\\_/\\ ║
|
|
║ ( o.o ) HENDRIK ║
|
|
║ > ^ < ║
|
|
║ ( ) AI Agent ║
|
|
║ (___) ║
|
|
║ ║
|
|
╚══════════════════════════════════════════╝
|
|
"""
|
|
|
|
def log(app, role, text, model_info=None, usage=None):
|
|
entry = {
|
|
"role": role,
|
|
"text": text,
|
|
"time": datetime.now().strftime("%H:%M"),
|
|
}
|
|
if model_info:
|
|
# Display pair: (provider, model)
|
|
entry["model_info"] = model_info
|
|
if usage:
|
|
# Token pemakaian: prompt/completion/total dari provider
|
|
entry["usage"] = usage
|
|
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, usage=None):
|
|
"""Tempelkan info model (dan token usage) 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 and not usage:
|
|
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:
|
|
if pair:
|
|
entry["model_info"] = pair
|
|
if usage:
|
|
entry["usage"] = usage
|
|
|
|
|
|
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.usage = None # token pemakaian langkah LLM terakhir
|
|
self._buf = []
|
|
self._placeholder = None
|
|
self._stamp = None
|
|
self._stamp_step = None
|
|
self._tool_total = 0 # jumlah tool call yang dijadwalkan langkah ini
|
|
self._tool_seen = 0 # tool call yang sudah di-log langkah ini
|
|
|
|
# ------------------------------------------------------------- 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
|
|
self.usage = None
|
|
self._tool_total = 0
|
|
self._tool_seen = 0
|
|
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, usage=None):
|
|
self.pair = _pair(model_info)
|
|
# Token pemakaian langkah LLM ini (untuk label usage di entri ai).
|
|
# Total satu turn sudah diakumulasi engine di AgentResult.usage.
|
|
self.usage = usage
|
|
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()
|
|
# Baris token satu panggilan LLM dititipkan ke tool_call TERAKHIR dari
|
|
# langkah ini (lihat on_tool_call), supaya urutan render tetap benar.
|
|
self._tool_total = len(names)
|
|
self._tool_seen = 0
|
|
if content and content.strip():
|
|
log(self.app, "ai", content, model_info=self.pair, usage=self.usage)
|
|
_tag_ai_logs(self.app, self._log_prev, self.pair)
|
|
|
|
def on_tool_call(self, name, arguments):
|
|
entry = log(self.app, "tool_call", json.dumps({
|
|
"name": name,
|
|
"arguments": arguments,
|
|
}), model_info=self.pair)
|
|
self._tool_seen += 1
|
|
if self.usage and self._tool_seen >= self._tool_total:
|
|
entry["usage"] = self.usage
|
|
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:
|
|
entry = log(self.app, "ai", "[Image generated]",
|
|
model_info=self.pair, usage=result.usage)
|
|
elif content is None:
|
|
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
|
|
entry = None
|
|
elif self._placeholder is None and content:
|
|
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
|
|
# delta) — pastikan teks tetap terlihat.
|
|
entry = log(self.app, "ai", content,
|
|
model_info=self.pair, usage=result.usage)
|
|
else:
|
|
# placeholder streaming tidak perlu diisi ulang: isinya sudah teks
|
|
# final. Gunakan sebagai target tempelan usage.
|
|
entry = self._placeholder
|
|
|
|
# Total token satu turn (akumulasi semua langkah LLM) ditaruh di
|
|
# entri respons final, dirender setelah teks pesan.
|
|
if entry is not None and result.usage:
|
|
entry["usage"] = result.usage
|
|
|
|
_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.")
|
|
|
|
def on_cancelled(self, result):
|
|
"""Turn dihentikan user. Berjalan di thread agen — satu-satunya writer
|
|
app.log, jadi tidak ada balapan dengan main loop."""
|
|
self._pop_thinking()
|
|
# Sisa turn dibuang dari konteks (assistant parsial dengan tool_calls
|
|
# tanpa hasil = payload tidak valid), tapi teks yang sudah terbaca tetap
|
|
# ditampilkan supaya user tidak kehilangan apa yang sudah dihasilkan.
|
|
partial = result.partial or ""
|
|
self._drop_placeholder()
|
|
if partial.strip():
|
|
entry = log(self.app, "ai", partial, model_info=self.pair,
|
|
usage=result.usage)
|
|
entry["cancelled"] = True
|
|
log(self.app, "system", " Inference cancelled by user")
|
|
log(self.app, "sep", "")
|
|
self._follow()
|
|
|
|
# ----------------------------------------------------------------- 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,
|
|
on_truncate=lambda n: _truncate_msg(app, n),
|
|
)
|
|
try:
|
|
agent_loop.run_agent_loop(
|
|
store,
|
|
app.llm,
|
|
app.TOOLS,
|
|
app.TOOL_HANDLERS,
|
|
app.agent_max_iterations,
|
|
observer=CursesObserver(app),
|
|
tool_reminder=None,
|
|
)
|
|
finally:
|
|
# Selalu tandai selesai, meskipun engine melempar. Tanpa ini UI bisa
|
|
# terkunci di PROCESSING selamanya kalau ada exception tak terduga
|
|
# (mis. saat pembatalan).
|
|
app.agent_done.set()
|
|
|
|
|
|
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)
|
|
|
|
# Turn baru: bersihkan state cancel sebelumnya supaya cancel yang tertunda
|
|
# tidak membatalkan prompt yang baru dikirim.
|
|
app.llm.begin_turn()
|
|
app.agent_done.clear()
|
|
app.agent_thread = threading.Thread(
|
|
target=_run_agent,
|
|
args=(app,),
|
|
daemon=True,
|
|
)
|
|
app.agent_thread.start()
|