Merge branch 'product/public' into product/kodachara
This commit is contained in:
commit
731191805b
@ -28,6 +28,8 @@ asr: # Speech-to-Text (push-to-talk TUI). Model dikelola via moop tipe 'asr'.
|
||||
max_seconds : 60 # auto-stop pengaman
|
||||
transcribe_timeout: 60 # detik timeout request ASR
|
||||
keep_recordings : true # false = hapus file setelah ditranskrip
|
||||
auto_submit : false # true = hasil transkrip langsung dikirim
|
||||
# false = hanya mengisi form (Ctrl+Enter)
|
||||
|
||||
session: # Chat session
|
||||
db_path: "~/.config/hendrik/sessions.json"
|
||||
|
||||
@ -61,6 +61,10 @@ ASR_KEEP_RECORDINGS = _as_bool(_yaml_get("asr", "keep_recordings", default=True)
|
||||
# Tombol push-to-talk: "f1".."f12" atau "ctrl-<huruf>". Default F9.
|
||||
ASR_RECORD_KEY = str(_yaml_get("asr", "record_key", default="f9")).strip().lower()
|
||||
|
||||
# true = hasil transkripsi langsung dikirim sebagai pesan,
|
||||
# false = hasil hanya disisipkan ke form input (user kirim sendiri via Ctrl+Enter).
|
||||
ASR_AUTO_SUBMIT = _as_bool(_yaml_get("asr", "auto_submit", default=False), default=False)
|
||||
|
||||
|
||||
XMPP_USERNAME = _yaml_get("xmpp", "username", default="")
|
||||
XMPP_PASSWORD = _yaml_get("xmpp", "password", default="")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -2,9 +2,13 @@
|
||||
#
|
||||
# Alur:
|
||||
# * F9 (configurable) → mulai rekam (ffmpeg subprocess, non-blocking).
|
||||
# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil DISISIPKAN
|
||||
# ke form input pada posisi kursor, TIDAK submit.
|
||||
# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil
|
||||
# disisipkan ke form input pada posisi kursor,
|
||||
# TIDAK submit (default).
|
||||
# * Backspace / Esc → batal: rekaman dibuang / hasil transkrip diabaikan.
|
||||
# * config.asr.auto_submit: true → hasil transkrip langsung dikirim sebagai
|
||||
# pesan (setara Ctrl+Enter). Jika form sudah terisi
|
||||
# saat transkrip selesai, hasil tetap disisipkan.
|
||||
#
|
||||
# State disimpan di app:
|
||||
# app.talk_recorder — instance Recorder saat merekam, else None
|
||||
@ -18,7 +22,7 @@ import threading
|
||||
import config
|
||||
from tools import asr as asr_mod
|
||||
|
||||
from .agent import log
|
||||
from .agent import log, submit
|
||||
|
||||
|
||||
def _shortcut_label():
|
||||
@ -209,7 +213,8 @@ def shutdown(app):
|
||||
|
||||
|
||||
def finish_talk(app, stdscr):
|
||||
"""Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil."""
|
||||
"""Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil (atau
|
||||
langsung submit bila config.asr.auto_submit aktif)."""
|
||||
res = getattr(app, "_talk_result", None)
|
||||
app._talk_result = None
|
||||
if getattr(app, "talk_cancelled", False):
|
||||
@ -222,6 +227,27 @@ def finish_talk(app, stdscr):
|
||||
if not text:
|
||||
log(app, "system", " Tidak ada ucapan terdeteksi.")
|
||||
return
|
||||
if config.ASR_AUTO_SUBMIT:
|
||||
# Auto-submit: taruh teks di buffer lalu kirim (submit() yang
|
||||
# membersihkan buffer). Jika user sudah mengetik sesuatu sebelum
|
||||
# transkrip selesai, teks tidak ditimpa — disisipkan saja.
|
||||
if not "\n".join(app.input_buffer).strip():
|
||||
app.input_buffer = text.split("\n")
|
||||
app.input_line = len(app.input_buffer) - 1
|
||||
app.input_col = len(app.input_buffer[-1])
|
||||
submit(app, stdscr)
|
||||
log(app, "system",
|
||||
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
|
||||
"teks otomatis dikirim")
|
||||
else:
|
||||
insert_text(app, text)
|
||||
log(app, "system",
|
||||
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
|
||||
"form sedang terisi \u2014 hasil disisipkan, tidak auto-submit "
|
||||
"(Ctrl+Enter untuk kirim)")
|
||||
if app.scroll_follow:
|
||||
app.scroll = 999999
|
||||
return
|
||||
if insert_text(app, text):
|
||||
log(app, "system",
|
||||
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
|
||||
|
||||
@ -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 <thinking> 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user