254 lines
9.8 KiB
Python
254 lines
9.8 KiB
Python
import json
|
|
import threading
|
|
from datetime import datetime
|
|
import config
|
|
from lib import ntro, agent_loop
|
|
from tools.vision import ImagePayload
|
|
|
|
|
|
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
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
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):
|
|
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=_agent_loop,
|
|
args=(app,),
|
|
daemon=True,
|
|
)
|
|
app.agent_thread.start()
|
|
|
|
|
|
def _agent_loop(app):
|
|
stamp = ntro.start()
|
|
|
|
for step in range(app.agent_max_iterations):
|
|
stamp_step = ntro.start()
|
|
log(app, "system", f" step {step + 1} \u2014 Thinking...")
|
|
if app.scroll_follow:
|
|
app.scroll = 999999
|
|
|
|
# Streaming response
|
|
stream_buffer = []
|
|
placeholder_marker = None
|
|
|
|
def on_stream_chunk(chunk):
|
|
nonlocal placeholder_marker
|
|
# Buat placeholder di chunk pertama saja
|
|
if placeholder_marker is None:
|
|
placeholder_marker = f"_stream_placeholder_{step}"
|
|
log(app, "ai", placeholder_marker)
|
|
stream_buffer.append(chunk)
|
|
current_text = ''.join(stream_buffer)
|
|
# Update placeholder secara real-time
|
|
for i in range(len(app.log) - 1, -1, -1):
|
|
# Cari placeholder berdasarkan content aslinya (atau apa yang sudah diupdate)
|
|
# Karena placeholder text berubah seiring streaming, kita harus teliti
|
|
if app.log[i].get('role') == 'ai' and (app.log[i].get('text') == placeholder_marker or (i > 0 and app.log[i-1].get('role') == 'system' and 'Thinking' in app.log[i-1].get('text', ''))):
|
|
app.log[i]['text'] = current_text
|
|
break
|
|
if app.scroll_follow:
|
|
app.scroll = 999999
|
|
|
|
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', ''):
|
|
app.log.pop(i)
|
|
break
|
|
|
|
if response.warning:
|
|
log(app, "system", f" {response.warning}")
|
|
|
|
# Cek apakah response adalah error dari LLM client
|
|
content = response.content
|
|
is_error = isinstance(content, str) and (content.startswith("Error:") or content.startswith("HTTP Error:"))
|
|
|
|
if is_error:
|
|
# Hapus "Thinking..." log dan placeholder
|
|
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', ''):
|
|
app.log.pop(i)
|
|
break
|
|
if placeholder_marker is not None:
|
|
for i in range(len(app.log) - 1, -1, -1):
|
|
if app.log[i].get('role') == 'ai':
|
|
text = app.log[i].get('text', '')
|
|
if placeholder_marker in text or text == "" or text == "...":
|
|
app.log.pop(i)
|
|
break
|
|
log(app, "error", content)
|
|
_add_msg(app, "assistant", content)
|
|
log(app, "sep", "")
|
|
ntro.end(stamp)
|
|
app.agent_done.set()
|
|
return
|
|
|
|
# Cek apakah ada tool_calls
|
|
has_tool_calls = bool(response.tool_calls)
|
|
|
|
if has_tool_calls:
|
|
# Hapus placeholder jika ada tool_calls (cari semua AI log yang mungkin placeholder)
|
|
if placeholder_marker is not None:
|
|
for i in range(len(app.log) - 1, -1, -1):
|
|
# Jika ini adalah log AI yang muncul tepat setelah "Thinking..." atau contains placeholder marker
|
|
if app.log[i].get('role') == 'ai':
|
|
text = app.log[i].get('text', '')
|
|
# Hapus jika text-nya adalah placeholder atau jika itu adalah entry AI yang baru saja kita buat untuk streaming
|
|
if placeholder_marker in text or text == "" or text == "...":
|
|
app.log.pop(i)
|
|
|
|
_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, model_info=pair)
|
|
_tag_ai_logs(app, log_prev, pair)
|
|
|
|
# Log dan execute tool_calls
|
|
for tc in response.tool_calls:
|
|
tname = tc["function"]["name"]
|
|
targs = tc["function"]["arguments"]
|
|
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)
|
|
if isinstance(result, ImagePayload):
|
|
_add_msg(app, "tool", [
|
|
{"type": "text", "text": f"[Image loaded: {result.path}]"},
|
|
{"type": "image_url", "image_url": {"url": f"data:{result.mime};base64,{result.data}"}}
|
|
], tool_call_id=tc["id"])
|
|
else:
|
|
_add_msg(app, "tool", str(result), tool_call_id=tc["id"])
|
|
else:
|
|
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]", 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()
|
|
return
|
|
ntro.end(stamp_step)
|
|
|
|
log(app, "error", "Max iterations reached without final answer.")
|
|
_add_msg(app, "assistant", "Max iterations reached without final answer.")
|
|
ntro.end(stamp)
|
|
app.agent_done.set()
|
|
|
|
|
|
app.agent_done.set()
|
|
ntro.end(stamp)
|
|
|