Compare commits
4 Commits
0fa6fc9db9
...
07dd99ded4
| Author | SHA1 | Date | |
|---|---|---|---|
| 07dd99ded4 | |||
| 3b5ef010df | |||
| 5b89345f8a | |||
| 6d503c2427 |
24
.env.example
24
.env.example
@ -1,23 +1,5 @@
|
|||||||
# OpenRouter (cloud)
|
XMPP_USERNAME=
|
||||||
# LLM_BASE_URL=https://openrouter.ai/api/v1
|
XMPP_PASSWORD=
|
||||||
# LLM_MODEL=openrouter/owl-alpha
|
|
||||||
# LLM_API_KEY=
|
|
||||||
|
|
||||||
# Ollama (local)
|
TELEGRAM_TOKEN=
|
||||||
# LLM_BASE_URL=http://localhost:11434/v1
|
|
||||||
# LLM_MODEL=granite4.1:8b
|
|
||||||
# LLM_API_KEY=ollama
|
|
||||||
|
|
||||||
# Ollama (cloud)
|
|
||||||
# LLM_BASE_URL=https://ollama.com/v1
|
|
||||||
# LLM_MODEL=ministral-3:14b-cloud
|
|
||||||
# LLM_API_KEY=
|
|
||||||
|
|
||||||
# LM Studio (local)
|
|
||||||
# LLM_BASE_URL=http://localhost:12345/v1
|
|
||||||
# LLM_MODEL=granite4.1:8b
|
|
||||||
# LLM_API_KEY=sk-not-needed
|
|
||||||
|
|
||||||
# XMPP_USERNAME=
|
|
||||||
# XMPP_PASSWORD=
|
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
skill: programmer
|
skill : programmer
|
||||||
name: Hendrik
|
name : Hendrik
|
||||||
age: 35
|
age : 35
|
||||||
gender: male
|
gender : male
|
||||||
tone: casual
|
tone : casual
|
||||||
verbosity: concise
|
verbosity : concise
|
||||||
humor: none
|
humor : none
|
||||||
language: id
|
language : id
|
||||||
mood: calm
|
mood : calm
|
||||||
|
|||||||
70
config.py
70
config.py
@ -6,16 +6,14 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# ─── YAML Config Loader ────────────────────────────────────────────────────────
|
def _yaml_get(*keys, default=None):
|
||||||
|
|
||||||
_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml"
|
_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml"
|
||||||
_yaml: dict = {}
|
_yaml: dict = {}
|
||||||
if _CONFIG_PATH.is_file():
|
if _CONFIG_PATH.is_file():
|
||||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
_yaml = yaml.safe_load(f) or {}
|
_yaml = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
def _yaml_get(*keys, default=None):
|
|
||||||
"""Navigate nested yaml dict, return default if any key missing."""
|
|
||||||
d = _yaml
|
d = _yaml
|
||||||
for k in keys:
|
for k in keys:
|
||||||
if isinstance(d, dict) and k in d:
|
if isinstance(d, dict) and k in d:
|
||||||
@ -24,26 +22,58 @@ def _yaml_get(*keys, default=None):
|
|||||||
return default
|
return default
|
||||||
return d if d is not None else default
|
return d if d is not None else default
|
||||||
|
|
||||||
|
# Credential / Secret (only from .env)
|
||||||
|
llm_baseurl = os.getenv("LLM_BASE_URL", default="")
|
||||||
|
llm_model = os.getenv("LLM_MODEL", default="")
|
||||||
|
llm_api_key = os.getenv("LLM_API_KEY", default="")
|
||||||
|
|
||||||
# ─── Credential / Secret (hanya dari .env) ─────────────────────────────────────
|
llm_timeout = int(_yaml_get("llm", "timeout", default=600))
|
||||||
|
|
||||||
llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1")
|
|
||||||
llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b")
|
|
||||||
llm_api_key = os.getenv("LLM_API_KEY", default="ollama")
|
|
||||||
llm_timeout = int(os.getenv("LLM_TIMEOUT", default="600"))
|
|
||||||
|
|
||||||
XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="")
|
XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="")
|
||||||
XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="")
|
XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="")
|
||||||
|
|
||||||
|
MODELS_ITEMS: list[dict] = []
|
||||||
|
_models_config = _yaml_get("models", default={})
|
||||||
|
_items = _models_config.get("items", [])
|
||||||
|
if isinstance(_items, list):
|
||||||
|
for entry in _items:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
model = entry.get("model", "")
|
||||||
|
provider = entry.get("provider", "")
|
||||||
|
base_url = entry.get("base_url", "").rstrip("/")
|
||||||
|
api_key = entry.get("key", "") or llm_api_key
|
||||||
|
MODELS_ITEMS.append({
|
||||||
|
"model": model,
|
||||||
|
"provider": provider,
|
||||||
|
"base_url": base_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"default": entry.get("default", False),
|
||||||
|
})
|
||||||
|
|
||||||
# ─── Agent Config (YAML, bisa di-override dari .env) ────────────────────────────
|
def resolve_provider(base_url: str, model: str) -> str | None:
|
||||||
|
"""Cari nama provider yg cocok dengan (base_url, model) dari MODELS_ITEMS."""
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
for item in MODELS_ITEMS:
|
||||||
|
if item["base_url"] == base_url and item["model"] == model:
|
||||||
|
return item["provider"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
_has_match = any(
|
||||||
|
item["model"] == llm_model and item["base_url"] == llm_baseurl.rstrip("/")
|
||||||
|
for item in MODELS_ITEMS
|
||||||
|
)
|
||||||
|
if not _has_match:
|
||||||
|
for item in MODELS_ITEMS:
|
||||||
|
if item.get("default"):
|
||||||
|
llm_model = item["model"]
|
||||||
|
llm_baseurl = item["base_url"]
|
||||||
|
llm_api_key = item["api_key"]
|
||||||
|
break
|
||||||
|
|
||||||
AGENT_MAX_ITERATIONS = int(os.getenv("AGENT_MAX_ITERATIONS", default=_yaml_get("agent", "max_iterations", default="30")))
|
AGENT_MAX_ITERATIONS = int(os.getenv("AGENT_MAX_ITERATIONS", default=_yaml_get("agent", "max_iterations", default="30")))
|
||||||
AGENT_MAX_TOOL_OUTPUT = int(os.getenv("AGENT_MAX_TOOL_OUTPUT", default=_yaml_get("agent", "max_tool_output", default="40000")))
|
AGENT_MAX_TOOL_OUTPUT = int(os.getenv("AGENT_MAX_TOOL_OUTPUT", default=_yaml_get("agent", "max_tool_output", default="40000")))
|
||||||
|
|
||||||
|
|
||||||
# ─── Persona / Mode (YAML, bisa di-override dari .env) ──────────────────────────
|
|
||||||
|
|
||||||
AGENT_SKILL = os.getenv("AGENT_SKILL", default="programmer").strip().lower()
|
AGENT_SKILL = os.getenv("AGENT_SKILL", default="programmer").strip().lower()
|
||||||
PERSONA_NAME = os.getenv("PERSONA_NAME", default=_yaml_get("persona", "name", default="Hendrik")).strip() or "Hendrik"
|
PERSONA_NAME = os.getenv("PERSONA_NAME", default=_yaml_get("persona", "name", default="Hendrik")).strip() or "Hendrik"
|
||||||
PERSONA_AGE = os.getenv("PERSONA_AGE", default=_yaml_get("persona", "age", default="")).strip()
|
PERSONA_AGE = os.getenv("PERSONA_AGE", default=_yaml_get("persona", "age", default="")).strip()
|
||||||
@ -71,6 +101,14 @@ XMPP_NICKNAME = os.getenv("XMPP_NICKNAME", default=_yaml_get("xmpp", "nickname
|
|||||||
XMPP_SELECTIVE_RESPONSE = os.getenv("XMPP_SELECTIVE_RESPONSE", default=str(_yaml_get("xmpp", "selective_response", default="true"))).strip().lower() in ("true", "1", "yes")
|
XMPP_SELECTIVE_RESPONSE = os.getenv("XMPP_SELECTIVE_RESPONSE", default=str(_yaml_get("xmpp", "selective_response", default="true"))).strip().lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Telegram (non-credential dari YAML, credential dari .env) ─────────────────
|
||||||
|
|
||||||
|
TELEGRAM_ENABLED = os.getenv("TELEGRAM_ENABLED", default=str(_yaml_get("telegram", "enabled", default="false"))).strip().lower() in ("true", "1", "yes")
|
||||||
|
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", default="")
|
||||||
|
TELEGRAM_ALLOWED_GROUP_IDS = os.getenv("TELEGRAM_ALLOWED_GROUP_IDS", default=_yaml_get("telegram", "allowed_group_ids", default="")).strip()
|
||||||
|
TELEGRAM_SELECTIVE_RESPONSE = os.getenv("TELEGRAM_SELECTIVE_RESPONSE", default=str(_yaml_get("telegram", "selective_response", default="true"))).strip().lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
# ─── RAG (YAML) ─────────────────────────────────────────────────────────────────
|
# ─── RAG (YAML) ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default=_yaml_get("rag", "persist_dir", default="chroma_db"))
|
RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default=_yaml_get("rag", "persist_dir", default="chroma_db"))
|
||||||
|
|||||||
50
config.yaml
50
config.yaml
@ -1,20 +1,60 @@
|
|||||||
|
llm:
|
||||||
|
timeout: 3000 # second
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
max_iterations: 40
|
max_iterations: 40 # step
|
||||||
max_tool_output: 40000
|
max_tool_output: 40000
|
||||||
character: lily # Directory name in agent/characters/<character>/
|
character: hendrik # Directory name in agent/characters/<character>/
|
||||||
|
|
||||||
xmpp:
|
xmpp:
|
||||||
enabled: true
|
enabled: false
|
||||||
muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server"
|
muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server"
|
||||||
nickname: "" # custom MUC nickname (empty = use username)
|
nickname: "" # custom MUC nickname (empty = use username)
|
||||||
selective_response: true # true = only response if mentioned/relevant
|
selective_response: true # true = only response if mentioned/relevant
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
enabled: false
|
||||||
|
allowed_group_ids: "" # comma-separated, kosong = semua grup
|
||||||
|
selective_response: true
|
||||||
|
|
||||||
# Humanize Delay (anti-bot detection)
|
# Humanize Delay (anti-bot detection)
|
||||||
delay:
|
delay:
|
||||||
read_min: 1.0 # per second
|
read_min: 1.0 # second
|
||||||
read_max: 2.0 # per second
|
read_max: 2.0 # second
|
||||||
typing_speed: 15.0 # characters per second
|
typing_speed: 15.0 # characters per second
|
||||||
typing_max: 10.0 # max typing delay limit per second
|
typing_max: 10.0 # max typing delay limit per second
|
||||||
|
|
||||||
|
models:
|
||||||
|
items:
|
||||||
|
- model: "granite4.1:8b"
|
||||||
|
provider: "Ollama Local"
|
||||||
|
base_url: "http://localhost:11434/v1"
|
||||||
|
key: "ollama"
|
||||||
|
- model: "granite4.1:8b"
|
||||||
|
provider: "Transformers API Local"
|
||||||
|
base_url: "http://localhost:12345/v1"
|
||||||
|
key: "sk-not-needed"
|
||||||
|
- model: "ministral-3:14b-cloud"
|
||||||
|
provider: "Ollama Cloud"
|
||||||
|
base_url: "https://ollama.com/v1"
|
||||||
|
key: ""
|
||||||
|
- model: "gemma4:31b-cloud"
|
||||||
|
provider: "Ollama Cloud"
|
||||||
|
base_url: "https://ollama.com/v1"
|
||||||
|
key: ""
|
||||||
|
default: true
|
||||||
|
- model: "openrouter/owl-alpha"
|
||||||
|
provider: "OpenRouter"
|
||||||
|
base_url: "https://openrouter.ai/api/v1"
|
||||||
|
key: ""
|
||||||
|
- model: "nex-agi/nex-n2-pro:free"
|
||||||
|
provider: "OpenRouter"
|
||||||
|
base_url: "https://openrouter.ai/api/v1"
|
||||||
|
key: ""
|
||||||
|
- model: "z-ai/glm-5"
|
||||||
|
provider: "OpenRouter"
|
||||||
|
base_url: "https://openrouter.ai/api/v1"
|
||||||
|
key: ""
|
||||||
|
|
||||||
rag:
|
rag:
|
||||||
persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local)
|
persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local)
|
||||||
|
|||||||
2
hendrik
2
hendrik
@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# hendrik — wrapper to run the TUI agent from anywhere
|
# wrapper to run the TUI agent from anywhere
|
||||||
|
|
||||||
# Set HENDRIK_DIR env var to override, or update the default below
|
# Set HENDRIK_DIR env var to override, or update the default below
|
||||||
DEFAULT_DIR="/opt/hendrik"
|
DEFAULT_DIR="/opt/hendrik"
|
||||||
|
|||||||
56
hendrik.py
56
hendrik.py
@ -1,4 +1,6 @@
|
|||||||
import os, sys
|
import os, sys, threading, time
|
||||||
|
import signal
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
from services.xmpp_client import XMPPClient
|
from services.xmpp_client import XMPPClient
|
||||||
@ -32,6 +34,7 @@ tools_definition = [
|
|||||||
TOOLS = gadget.tool_schemas (tools_definition)
|
TOOLS = gadget.tool_schemas (tools_definition)
|
||||||
TOOL_HANDLERS = gadget.tool_handlers (tools_definition)
|
TOOL_HANDLERS = gadget.tool_handlers (tools_definition)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout)
|
llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout)
|
||||||
|
|
||||||
@ -51,6 +54,8 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
os.chdir(resolved)
|
os.chdir(resolved)
|
||||||
|
|
||||||
|
services = []
|
||||||
|
|
||||||
if config.XMPP_ENABLED:
|
if config.XMPP_ENABLED:
|
||||||
muc_rooms = []
|
muc_rooms = []
|
||||||
if config.XMPP_MUC_ROOMS.strip():
|
if config.XMPP_MUC_ROOMS.strip():
|
||||||
@ -66,7 +71,52 @@ def main():
|
|||||||
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
||||||
muc_rooms = muc_rooms,
|
muc_rooms = muc_rooms,
|
||||||
)
|
)
|
||||||
client.start()
|
services.append(client)
|
||||||
|
|
||||||
|
if config.TELEGRAM_ENABLED:
|
||||||
|
from services.telegram_client import TelegramClient
|
||||||
|
|
||||||
|
allowed_ids = []
|
||||||
|
if config.TELEGRAM_ALLOWED_GROUP_IDS.strip():
|
||||||
|
allowed_ids = [r.strip() for r in config.TELEGRAM_ALLOWED_GROUP_IDS.split(',') if r.strip()]
|
||||||
|
|
||||||
|
tg = TelegramClient(
|
||||||
|
token = config.TELEGRAM_TOKEN,
|
||||||
|
llm_client = llm_client,
|
||||||
|
tools_definition = tools_definition,
|
||||||
|
TOOLS = TOOLS,
|
||||||
|
TOOL_HANDLERS = TOOL_HANDLERS,
|
||||||
|
build_system_prompt = build_system_prompt,
|
||||||
|
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
||||||
|
allowed_group_ids = allowed_ids,
|
||||||
|
)
|
||||||
|
services.append(tg)
|
||||||
|
|
||||||
|
if services:
|
||||||
|
threads = []
|
||||||
|
for svc in services:
|
||||||
|
t = threading.Thread(target=svc.start, daemon=True)
|
||||||
|
t.start()
|
||||||
|
threads.append(t)
|
||||||
|
|
||||||
|
_shutdown = False
|
||||||
|
|
||||||
|
def _handle_sig(signum, frame):
|
||||||
|
nonlocal _shutdown
|
||||||
|
print("\nShutting down...")
|
||||||
|
for svc in services:
|
||||||
|
svc.stop()
|
||||||
|
_shutdown = True
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _handle_sig)
|
||||||
|
signal.signal(signal.SIGINT, _handle_sig)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not _shutdown:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
_shutdown = True
|
||||||
|
print("Exiting.")
|
||||||
else:
|
else:
|
||||||
from tui import HendrikTUI
|
from tui import HendrikTUI
|
||||||
HendrikTUI(
|
HendrikTUI(
|
||||||
@ -78,6 +128,6 @@ def main():
|
|||||||
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@ -3,3 +3,4 @@ PyYAML>=6.0
|
|||||||
chromadb>=0.5.0
|
chromadb>=0.5.0
|
||||||
openpyxl>=3.1.0
|
openpyxl>=3.1.0
|
||||||
slixmpp
|
slixmpp
|
||||||
|
python-telegram-bot>=20.0
|
||||||
|
|||||||
68
services/agent_loop.py
Normal file
68
services/agent_loop.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _ts():
|
||||||
|
return datetime.now().strftime('%H:%M:%S')
|
||||||
|
|
||||||
|
|
||||||
|
def execute_tool(tool_call, TOOL_HANDLERS):
|
||||||
|
tname = tool_call['function']['name']
|
||||||
|
targs = json.loads(tool_call['function']['arguments'])
|
||||||
|
handler = TOOL_HANDLERS.get(tname)
|
||||||
|
if not handler:
|
||||||
|
return f'Tool {tname} not found'
|
||||||
|
try:
|
||||||
|
if tname == 'search_code':
|
||||||
|
return handler(
|
||||||
|
pattern=targs['pattern'],
|
||||||
|
search_type=targs['search_type'],
|
||||||
|
path=targs.get('path', '.'),
|
||||||
|
)
|
||||||
|
elif tname == 'git_operation':
|
||||||
|
return handler(args=targs['args'])
|
||||||
|
else:
|
||||||
|
return handler(**targs)
|
||||||
|
except Exception as e:
|
||||||
|
return f'Error executing tool: {str(e)}'
|
||||||
|
|
||||||
|
|
||||||
|
def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on_tool_calls=None):
|
||||||
|
for step in range(max_iterations):
|
||||||
|
print(f'[{_ts()}] Step {step + 1} — calling LLM...')
|
||||||
|
response = llm_client.chat(session.messages, tools=TOOLS)
|
||||||
|
|
||||||
|
if response.tool_calls:
|
||||||
|
amsg = {
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': response.content,
|
||||||
|
'tool_calls': response.tool_calls,
|
||||||
|
}
|
||||||
|
session.messages.append(amsg)
|
||||||
|
|
||||||
|
tnames = [tc['function']['name'] for tc in response.tool_calls]
|
||||||
|
print(f'[{_ts()}] Using tools: {", ".join(tnames)}')
|
||||||
|
|
||||||
|
if on_tool_calls:
|
||||||
|
on_tool_calls(tnames)
|
||||||
|
|
||||||
|
for tc in response.tool_calls:
|
||||||
|
result = execute_tool(tc, TOOL_HANDLERS)
|
||||||
|
session.messages.append({
|
||||||
|
'role': 'tool',
|
||||||
|
'tool_call_id': tc['id'],
|
||||||
|
'content': str(result),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
if response.content:
|
||||||
|
print(f'[{_ts()}] Response generated ({len(response.content)} chars)')
|
||||||
|
session.messages.append({'role': 'assistant', 'content': response.content})
|
||||||
|
return response.content
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(f'[{_ts()}] Max iterations ({max_iterations}) reached')
|
||||||
|
session.messages.append({
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': 'Max iterations reached without final answer.',
|
||||||
|
})
|
||||||
|
return None
|
||||||
275
services/telegram_client.py
Normal file
275
services/telegram_client.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import config
|
||||||
|
from services.session_manager import SessionManager
|
||||||
|
from services.agent_loop import run_agent_loop
|
||||||
|
from scripts.persona import PERSONALITY
|
||||||
|
from tools.roleplayer import _name_mentioned
|
||||||
|
|
||||||
|
|
||||||
|
def _ts():
|
||||||
|
return datetime.now().strftime('%H:%M:%S')
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_bot_mention(text: str, bot_username: str) -> str:
|
||||||
|
if not bot_username:
|
||||||
|
return text
|
||||||
|
pattern = re.compile(r'@' + re.escape(bot_username), re.IGNORECASE)
|
||||||
|
return pattern.sub('', text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramClient:
|
||||||
|
def __init__(self, token, llm_client, tools_definition, TOOLS,
|
||||||
|
TOOL_HANDLERS, build_system_prompt, agent_max_iterations,
|
||||||
|
allowed_group_ids=None):
|
||||||
|
self._token = token
|
||||||
|
self._llm = llm_client
|
||||||
|
self._tools_def = tools_definition
|
||||||
|
self._TOOLS = TOOLS
|
||||||
|
self._TOOL_HANDLERS = TOOL_HANDLERS
|
||||||
|
self._build_system_prompt = build_system_prompt
|
||||||
|
self._max_iterations = agent_max_iterations
|
||||||
|
self._allowed_group_ids = allowed_group_ids or []
|
||||||
|
self._skill = config.AGENT_SKILL
|
||||||
|
self._bot_username = ""
|
||||||
|
|
||||||
|
self._session_mgr = SessionManager()
|
||||||
|
self._loop = None
|
||||||
|
self._stopped = None
|
||||||
|
|
||||||
|
from telegram.ext import Application
|
||||||
|
self._app = Application.builder().token(token).build()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
print(f'[{_ts()}] Starting Telegram service...')
|
||||||
|
asyncio.run(self._async_run())
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._loop and not self._loop.is_closed():
|
||||||
|
asyncio.run_coroutine_threadsafe(self._async_stop(), self._loop)
|
||||||
|
|
||||||
|
async def _async_stop(self):
|
||||||
|
self._stopped.set()
|
||||||
|
|
||||||
|
async def _async_run(self):
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self._stopped = asyncio.Event()
|
||||||
|
|
||||||
|
bot_user = await self._app.bot.get_me()
|
||||||
|
self._bot_username = bot_user.username or ""
|
||||||
|
print(f'[{_ts()}] Telegram bot: @{self._bot_username}')
|
||||||
|
|
||||||
|
self._register_handlers()
|
||||||
|
|
||||||
|
await self._app.initialize()
|
||||||
|
await self._app.start()
|
||||||
|
await self._app.updater.start_polling()
|
||||||
|
print(f'[{_ts()}] Telegram bot is polling')
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._stopped.wait()
|
||||||
|
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self._app.updater.stop()
|
||||||
|
await self._app.stop()
|
||||||
|
await self._app.shutdown()
|
||||||
|
print(f'[{_ts()}] Telegram service stopped')
|
||||||
|
|
||||||
|
def _register_handlers(self):
|
||||||
|
from telegram.ext import MessageHandler, filters, CommandHandler
|
||||||
|
|
||||||
|
self._app.add_handler(
|
||||||
|
MessageHandler(
|
||||||
|
filters.TEXT & ~filters.COMMAND & filters.ChatType.PRIVATE,
|
||||||
|
self._on_private_message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._app.add_handler(
|
||||||
|
MessageHandler(
|
||||||
|
filters.TEXT & ~filters.COMMAND & filters.ChatType.GROUPS,
|
||||||
|
self._on_group_message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._app.add_handler(CommandHandler('start', self._on_start_command))
|
||||||
|
self._app.add_handler(CommandHandler('new', self._on_new_command))
|
||||||
|
|
||||||
|
async def _on_start_command(self, update, context):
|
||||||
|
await update.message.reply_text(
|
||||||
|
'Halo! Aku adalah asisten AI. Kirim pesan untuk memulai percakapan.'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _on_new_command(self, update, context):
|
||||||
|
chat_id = str(update.effective_chat.id)
|
||||||
|
self._session_mgr.reset(chat_id)
|
||||||
|
await update.message.reply_text('Memulai sesi baru. Ada yang bisa dibantu?')
|
||||||
|
|
||||||
|
async def _on_private_message(self, update, context):
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
text = update.message.text.strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
msg_id = update.message.message_id
|
||||||
|
print(f'[{_ts()}] Telegram DM from {chat_id}: {text[:60]}')
|
||||||
|
threading.Thread(
|
||||||
|
target=self._process_message,
|
||||||
|
args=(chat_id, text, 'private', '', msg_id),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
async def _on_group_message(self, update, context):
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
chat_id_str = str(chat_id)
|
||||||
|
|
||||||
|
if self._allowed_group_ids and chat_id_str not in self._allowed_group_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
text = update.message.text.strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
replied_to_bot = (
|
||||||
|
update.message.reply_to_message
|
||||||
|
and update.message.reply_to_message.from_user
|
||||||
|
and update.message.reply_to_message.from_user.id == context.bot.id
|
||||||
|
)
|
||||||
|
|
||||||
|
has_mention = False
|
||||||
|
if update.message.entities:
|
||||||
|
for ent in update.message.entities:
|
||||||
|
if ent.type == 'mention' and self._bot_username:
|
||||||
|
start = ent.offset
|
||||||
|
end = ent.offset + ent.length
|
||||||
|
mention_text = update.message.text[start:end]
|
||||||
|
if mention_text.lower() == f'@{self._bot_username.lower()}':
|
||||||
|
has_mention = True
|
||||||
|
break
|
||||||
|
elif ent.type == 'text_mention' and ent.user.id == context.bot.id:
|
||||||
|
has_mention = True
|
||||||
|
break
|
||||||
|
|
||||||
|
name_mentioned = _name_mentioned(PERSONALITY.name, text)
|
||||||
|
|
||||||
|
if not replied_to_bot and not has_mention and not name_mentioned:
|
||||||
|
print(f'[{_ts()}] Telegram Group [{chat_id}] NO-REPLY: {text[:60]}')
|
||||||
|
return
|
||||||
|
|
||||||
|
if has_mention and self._bot_username:
|
||||||
|
text = _strip_bot_mention(text, self._bot_username)
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
msg_id = update.message.message_id
|
||||||
|
sender = update.effective_user
|
||||||
|
sender_name = sender.full_name or sender.username or str(sender.id) if sender else str(chat_id)
|
||||||
|
print(f'[{_ts()}] Telegram Group [{chat_id}] <{sender_name}>: {text[:60]}')
|
||||||
|
threading.Thread(
|
||||||
|
target=self._process_message,
|
||||||
|
args=(chat_id, text, 'group', sender_name, msg_id),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _process_message(self, chat_id, body, chat_type, sender_name, reply_to_msg_id):
|
||||||
|
session = self._session_mgr.get_or_create(
|
||||||
|
str(chat_id), self._build_system_prompt(
|
||||||
|
tools_definition=self._tools_def,
|
||||||
|
character=config.AGENT_CHARACTER or None,
|
||||||
|
skills=config.AGENT_SKILLS.split(",") if config.AGENT_SKILLS else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.cancel_timer()
|
||||||
|
|
||||||
|
if body in (':new', '/new'):
|
||||||
|
self._session_mgr.reset(str(chat_id))
|
||||||
|
print(f'[{_ts()}] Session reset for {chat_id}')
|
||||||
|
self._schedule_send(chat_id, 'Memulai sesi baru. Ada yang bisa dibantu?', reply_to_msg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
session.add_message('user', body)
|
||||||
|
is_roleplay = self._skill == 'roleplayer'
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._app.bot.send_chat_action(chat_id=chat_id, action='typing'),
|
||||||
|
self._loop
|
||||||
|
)
|
||||||
|
|
||||||
|
delay = random.uniform(config.READ_DELAY_MIN, config.READ_DELAY_MAX)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
def on_tool_calls(tnames):
|
||||||
|
info = f'Using: {", ".join(tnames)}'
|
||||||
|
print(f'[{_ts()}] {info}')
|
||||||
|
if not is_roleplay:
|
||||||
|
self._schedule_send(chat_id, info, reply_to_msg_id)
|
||||||
|
|
||||||
|
final_content = run_agent_loop(
|
||||||
|
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
|
||||||
|
self._max_iterations, on_tool_calls=on_tool_calls
|
||||||
|
)
|
||||||
|
|
||||||
|
if final_content is not None:
|
||||||
|
if is_roleplay:
|
||||||
|
my_name = PERSONALITY.name
|
||||||
|
if config.TELEGRAM_SELECTIVE_RESPONSE:
|
||||||
|
recent_msgs = []
|
||||||
|
for msg in session.messages[-6:]:
|
||||||
|
if msg.get('role') == 'user':
|
||||||
|
recent_msgs.append(f"User: {msg.get('content', '')}")
|
||||||
|
elif msg.get('role') == 'assistant' and msg.get('content'):
|
||||||
|
recent_msgs.append(f"{my_name}: {msg.get('content', '')}")
|
||||||
|
recent_history = "\n".join(recent_msgs)
|
||||||
|
|
||||||
|
from tools.roleplayer import should_respond
|
||||||
|
if should_respond(
|
||||||
|
message=body,
|
||||||
|
sender_nickname=sender_name or str(chat_id),
|
||||||
|
recent_history=recent_history,
|
||||||
|
my_name=my_name,
|
||||||
|
):
|
||||||
|
print(f'[{_ts()}] need_response=True → sending response')
|
||||||
|
self._schedule_send(chat_id, final_content, reply_to_msg_id)
|
||||||
|
else:
|
||||||
|
print(f'[{_ts()}] need_response=False → staying silent')
|
||||||
|
else:
|
||||||
|
from tools.roleplayer import _name_mentioned
|
||||||
|
if _name_mentioned(my_name, body):
|
||||||
|
print(f'[{_ts()}] Name mentioned → sending response')
|
||||||
|
self._schedule_send(chat_id, final_content, reply_to_msg_id)
|
||||||
|
else:
|
||||||
|
print(f'[{_ts()}] Name not mentioned → staying silent')
|
||||||
|
else:
|
||||||
|
self._schedule_send(chat_id, final_content, reply_to_msg_id)
|
||||||
|
else:
|
||||||
|
msg = 'Max iterations reached without final answer.'
|
||||||
|
self._schedule_send(chat_id, msg, reply_to_msg_id)
|
||||||
|
|
||||||
|
timeout = 86400 if chat_type == 'private' else 300
|
||||||
|
session.start_timer(timeout, self._timeout_session, chat_id)
|
||||||
|
|
||||||
|
def _schedule_send(self, chat_id, text, reply_to_msg_id=None):
|
||||||
|
if self._loop and not self._loop.is_closed():
|
||||||
|
char_count = len(text) if text else 0
|
||||||
|
sleep_delay = max(1.0, min(char_count / config.TYPING_SPEED, config.TYPING_MAX))
|
||||||
|
print(f'[{_ts()}] Typing delay: {sleep_delay:.1f}s ({char_count} chars)')
|
||||||
|
time.sleep(sleep_delay)
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
reply_to_message_id=reply_to_msg_id
|
||||||
|
),
|
||||||
|
self._loop
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f'[{_ts()}] WARNING: cannot send to {chat_id} — loop unavailable')
|
||||||
|
|
||||||
|
def _timeout_session(self, chat_id):
|
||||||
|
print(f'[{_ts()}] Session timeout: {chat_id}')
|
||||||
|
self._schedule_send(chat_id, 'Sesi ditutup. Sampai jumpa')
|
||||||
|
self._session_mgr.reset(str(chat_id))
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import random
|
import random
|
||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
@ -7,6 +6,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from slixmpp import ClientXMPP
|
from slixmpp import ClientXMPP
|
||||||
from services.session_manager import SessionManager
|
from services.session_manager import SessionManager
|
||||||
|
from services.agent_loop import run_agent_loop
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from tools.roleplayer import should_respond
|
from tools.roleplayer import should_respond
|
||||||
@ -306,7 +306,54 @@ class XMPPClient(ClientXMPP):
|
|||||||
if self._loop and not self._loop.is_closed():
|
if self._loop and not self._loop.is_closed():
|
||||||
asyncio.run_coroutine_threadsafe(_read_delay(), self._loop)
|
asyncio.run_coroutine_threadsafe(_read_delay(), self._loop)
|
||||||
|
|
||||||
self._agent_loop(session, jid, body, 'chat', sender_nickname=jid)
|
my_name = PERSONALITY.name
|
||||||
|
quote = body
|
||||||
|
|
||||||
|
def on_tool_calls(tnames):
|
||||||
|
if not is_roleplay:
|
||||||
|
self._schedule_send(jid, f'> {quote}\nUsing: {", ".join(tnames)}', 'chat')
|
||||||
|
|
||||||
|
final_content = run_agent_loop(
|
||||||
|
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
|
||||||
|
self._max_iterations, on_tool_calls=on_tool_calls
|
||||||
|
)
|
||||||
|
|
||||||
|
if final_content is not None:
|
||||||
|
if is_roleplay:
|
||||||
|
if config.XMPP_SELECTIVE_RESPONSE:
|
||||||
|
recent_msgs = []
|
||||||
|
for msg in session.messages[-6:]:
|
||||||
|
if msg.get('role') == 'user':
|
||||||
|
recent_msgs.append(f"User: {msg.get('content', '')}")
|
||||||
|
elif msg.get('role') == 'assistant' and msg.get('content'):
|
||||||
|
recent_msgs.append(f"{my_name}: {msg.get('content', '')}")
|
||||||
|
recent_history = "\n".join(recent_msgs)
|
||||||
|
|
||||||
|
if should_respond(
|
||||||
|
message=quote,
|
||||||
|
sender_nickname=jid,
|
||||||
|
recent_history=recent_history,
|
||||||
|
my_name=my_name,
|
||||||
|
):
|
||||||
|
print(f'[{_ts()}] need_response=True → sending response')
|
||||||
|
self._schedule_send(jid, final_content, 'chat')
|
||||||
|
else:
|
||||||
|
print(f'[{_ts()}] need_response=False → staying silent')
|
||||||
|
else:
|
||||||
|
from tools.roleplayer import _name_mentioned
|
||||||
|
if _name_mentioned(my_name, quote):
|
||||||
|
print(f'[{_ts()}] Name mentioned → sending response')
|
||||||
|
self._schedule_send(jid, final_content, 'chat')
|
||||||
|
else:
|
||||||
|
print(f'[{_ts()}] Name not mentioned → staying silent')
|
||||||
|
else:
|
||||||
|
self._schedule_send(jid, f'> {quote}\n{final_content}', 'chat')
|
||||||
|
else:
|
||||||
|
msg = 'Max iterations reached without final answer.'
|
||||||
|
if is_roleplay:
|
||||||
|
self._schedule_send(jid, msg, 'chat')
|
||||||
|
else:
|
||||||
|
self._schedule_send(jid, f'> {quote}\n{msg}', 'chat')
|
||||||
|
|
||||||
# DM: timeout 24 jam (efektif tidak auto-close), MUC tetap 5 menit
|
# DM: timeout 24 jam (efektif tidak auto-close), MUC tetap 5 menit
|
||||||
session.start_timer(86400, self._timeout_session, jid, 'chat')
|
session.start_timer(86400, self._timeout_session, jid, 'chat')
|
||||||
@ -337,49 +384,21 @@ class XMPPClient(ClientXMPP):
|
|||||||
if self._loop and not self._loop.is_closed():
|
if self._loop and not self._loop.is_closed():
|
||||||
asyncio.run_coroutine_threadsafe(_read_delay(), self._loop)
|
asyncio.run_coroutine_threadsafe(_read_delay(), self._loop)
|
||||||
|
|
||||||
self._agent_loop(session, room, f'[{nick}] {body}', 'groupchat', sender_nickname=nick)
|
|
||||||
|
|
||||||
session.start_timer(300, self._timeout_session, room, 'groupchat')
|
|
||||||
|
|
||||||
def _agent_loop(self, session, to, quote, mtype, sender_nickname=""):
|
|
||||||
is_roleplayer = self._skill == 'roleplayer'
|
|
||||||
my_name = PERSONALITY.name
|
my_name = PERSONALITY.name
|
||||||
|
quote = f'[{nick}] {body}'
|
||||||
|
|
||||||
for step in range(self._max_iterations):
|
def on_tool_calls(tnames):
|
||||||
print(f'[{_ts()}] Step {step + 1} — calling LLM...')
|
if not is_roleplay:
|
||||||
response = self._llm.chat(session.messages, tools=self._TOOLS)
|
self._schedule_send(room, f'> {quote}\nUsing: {", ".join(tnames)}', 'groupchat')
|
||||||
|
|
||||||
if response.tool_calls:
|
final_content = run_agent_loop(
|
||||||
amsg = {
|
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
|
||||||
'role': 'assistant',
|
self._max_iterations, on_tool_calls=on_tool_calls
|
||||||
'content': response.content,
|
)
|
||||||
'tool_calls': response.tool_calls,
|
|
||||||
}
|
|
||||||
session.messages.append(amsg)
|
|
||||||
|
|
||||||
tnames = [tc['function']['name'] for tc in response.tool_calls]
|
if final_content is not None:
|
||||||
print(f'[{_ts()}] Using tools: {", ".join(tnames)}')
|
if is_roleplay:
|
||||||
|
|
||||||
# Roleplayer tidak perlu kirim status "Using: ..."
|
|
||||||
if not is_roleplayer:
|
|
||||||
self._schedule_send(to, f'> {quote}\nUsing: {", ".join(tnames)}', mtype)
|
|
||||||
|
|
||||||
for tc in response.tool_calls:
|
|
||||||
result = self._execute_tool(tc)
|
|
||||||
session.messages.append({
|
|
||||||
'role': 'tool',
|
|
||||||
'tool_call_id': tc['id'],
|
|
||||||
'content': str(result),
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
if response.content:
|
|
||||||
print(f'[{_ts()}] Response generated ({len(response.content)} chars)')
|
|
||||||
session.messages.append({'role': 'assistant', 'content': response.content})
|
|
||||||
|
|
||||||
# ── Roleplayer: cek need_response sebelum kirim ──
|
|
||||||
if is_roleplayer:
|
|
||||||
if config.XMPP_SELECTIVE_RESPONSE:
|
if config.XMPP_SELECTIVE_RESPONSE:
|
||||||
# Build recent history dari session messages (tanpa system prompt)
|
|
||||||
recent_msgs = []
|
recent_msgs = []
|
||||||
for msg in session.messages[-6:]:
|
for msg in session.messages[-6:]:
|
||||||
if msg.get('role') == 'user':
|
if msg.get('role') == 'user':
|
||||||
@ -388,59 +407,37 @@ class XMPPClient(ClientXMPP):
|
|||||||
recent_msgs.append(f"{my_name}: {msg.get('content', '')}")
|
recent_msgs.append(f"{my_name}: {msg.get('content', '')}")
|
||||||
recent_history = "\n".join(recent_msgs)
|
recent_history = "\n".join(recent_msgs)
|
||||||
|
|
||||||
original_message = quote
|
|
||||||
if should_respond(
|
if should_respond(
|
||||||
message=original_message,
|
message=quote,
|
||||||
sender_nickname=sender_nickname,
|
sender_nickname=nick,
|
||||||
recent_history=recent_history,
|
recent_history=recent_history,
|
||||||
my_name=my_name,
|
my_name=my_name,
|
||||||
):
|
):
|
||||||
print(f'[{_ts()}] need_response=True → sending response')
|
print(f'[{_ts()}] need_response=True → sending response')
|
||||||
self._schedule_send(to, response.content, mtype)
|
self._schedule_send(room, final_content, 'groupchat')
|
||||||
else:
|
else:
|
||||||
print(f'[{_ts()}] need_response=False → staying silent')
|
print(f'[{_ts()}] need_response=False → staying silent')
|
||||||
else:
|
else:
|
||||||
# Selective response OFF: cuma respon kalau nama AI disebut di pesan
|
|
||||||
from tools.roleplayer import _name_mentioned
|
from tools.roleplayer import _name_mentioned
|
||||||
if _name_mentioned(my_name, quote):
|
if _name_mentioned(my_name, quote):
|
||||||
print(f'[{_ts()}] Name mentioned → sending response')
|
print(f'[{_ts()}] Name mentioned → sending response')
|
||||||
self._schedule_send(to, response.content, mtype)
|
self._schedule_send(room, final_content, 'groupchat')
|
||||||
else:
|
else:
|
||||||
print(f'[{_ts()}] Name not mentioned → staying silent')
|
print(f'[{_ts()}] Name not mentioned → staying silent')
|
||||||
else:
|
else:
|
||||||
self._schedule_send(to, f'> {quote}\n{response.content}', mtype)
|
self._schedule_send(room, f'> {quote}\n{final_content}', 'groupchat')
|
||||||
return
|
|
||||||
|
|
||||||
print(f'[{_ts()}] Max iterations ({self._max_iterations}) reached')
|
|
||||||
session.messages.append({
|
|
||||||
'role': 'assistant',
|
|
||||||
'content': 'Max iterations reached without final answer.',
|
|
||||||
})
|
|
||||||
|
|
||||||
if is_roleplayer:
|
|
||||||
self._schedule_send(to, 'Max iterations reached without final answer.', mtype)
|
|
||||||
else:
|
else:
|
||||||
self._schedule_send(to, f'> {quote}\nMax iterations reached without final answer.', mtype)
|
msg = 'Max iterations reached without final answer.'
|
||||||
|
if is_roleplay:
|
||||||
|
self._schedule_send(room, msg, 'groupchat')
|
||||||
|
else:
|
||||||
|
self._schedule_send(room, f'> {quote}\n{msg}', 'groupchat')
|
||||||
|
|
||||||
|
session.start_timer(300, self._timeout_session, room, 'groupchat')
|
||||||
|
|
||||||
def _execute_tool(self, tool_call):
|
def _execute_tool(self, tool_call):
|
||||||
tname = tool_call['function']['name']
|
from services.agent_loop import execute_tool
|
||||||
targs = json.loads(tool_call['function']['arguments'])
|
return execute_tool(tool_call, self._TOOL_HANDLERS)
|
||||||
handler = self._TOOL_HANDLERS.get(tname)
|
|
||||||
if not handler:
|
|
||||||
return f'Tool {tname} not found'
|
|
||||||
try:
|
|
||||||
if tname == 'search_code':
|
|
||||||
return handler(
|
|
||||||
pattern=targs['pattern'],
|
|
||||||
search_type=targs['search_type'],
|
|
||||||
path=targs.get('path', '.'),
|
|
||||||
)
|
|
||||||
elif tname == 'git_operation':
|
|
||||||
return handler(args=targs['args'])
|
|
||||||
else:
|
|
||||||
return handler(**targs)
|
|
||||||
except Exception as e:
|
|
||||||
return f'Error executing tool: {str(e)}'
|
|
||||||
|
|
||||||
def _schedule_send(self, to, body, mtype='chat'):
|
def _schedule_send(self, to, body, mtype='chat'):
|
||||||
if self._loop and not self._loop.is_closed():
|
if self._loop and not self._loop.is_closed():
|
||||||
@ -480,7 +477,7 @@ class XMPPClient(ClientXMPP):
|
|||||||
# dan kita tidak mau proses mati karena itu.
|
# dan kita tidak mau proses mati karena itu.
|
||||||
try:
|
try:
|
||||||
self._loop.add_signal_handler(signal.SIGTERM, self._stopped.set)
|
self._loop.add_signal_handler(signal.SIGTERM, self._stopped.set)
|
||||||
except NotImplementedError:
|
except (NotImplementedError, RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await self.connect()
|
await self.connect()
|
||||||
|
|||||||
@ -39,7 +39,6 @@ schema_sendhttprequest = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def sendhttprequest(url, method, authorization=None, content_type=None, data=None, params=None):
|
def sendhttprequest(url, method, authorization=None, content_type=None, data=None, params=None):
|
||||||
try:
|
try:
|
||||||
if params:
|
if params:
|
||||||
|
|||||||
@ -1,14 +1,50 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
|
schema_need_response = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "need_response",
|
||||||
|
"description": (
|
||||||
|
"Decide whether you should respond to the current message. "
|
||||||
|
"Use this tool when you are unsure whether to reply or stay silent. "
|
||||||
|
"Returns 'true' if you should respond, 'false' if you should stay silent. "
|
||||||
|
"Rules: "
|
||||||
|
"- Return 'true' if your name is mentioned or someone talks about you. "
|
||||||
|
"- Return 'true' if the message is related to your previous conversation. "
|
||||||
|
"- Return 'false' if the message is between other people, unclear, "
|
||||||
|
"unrelated to you, or you have nothing to add."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The latest message to evaluate.",
|
||||||
|
},
|
||||||
|
"sender_nickname": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The nickname of the person who sent the message.",
|
||||||
|
},
|
||||||
|
"recent_history": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Recent conversation history for context (last few messages).",
|
||||||
|
},
|
||||||
|
"my_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Your (the AI's) name.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["message", "sender_nickname", "recent_history", "my_name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def _name_mentioned(name: str, text: str) -> bool:
|
def _name_mentioned(name: str, text: str) -> bool:
|
||||||
"""Cek apakah nama AI disebut dalam pesan (case-insensitive, word-boundary)."""
|
|
||||||
text_lower = text.lower()
|
text_lower = text.lower()
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
pattern = r'\b' + re.escape(name_lower) + r'\b'
|
pattern = r'\b' + re.escape(name_lower) + r'\b'
|
||||||
return bool(re.search(pattern, text_lower))
|
return bool(re.search(pattern, text_lower))
|
||||||
|
|
||||||
|
|
||||||
def need_response(message: str, sender_nickname: str, recent_history: str, my_name: str) -> str:
|
def need_response(message: str, sender_nickname: str, recent_history: str, my_name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Decide whether the AI should respond to a message.
|
Decide whether the AI should respond to a message.
|
||||||
@ -61,42 +97,3 @@ def should_respond(message: str, sender_nickname: str, recent_history: str, my_n
|
|||||||
result = need_response(message, sender_nickname, recent_history, my_name)
|
result = need_response(message, sender_nickname, recent_history, my_name)
|
||||||
return result.strip().lower() == "true"
|
return result.strip().lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
schema_need_response = {
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "need_response",
|
|
||||||
"description": (
|
|
||||||
"Decide whether you should respond to the current message. "
|
|
||||||
"Use this tool when you are unsure whether to reply or stay silent. "
|
|
||||||
"Returns 'true' if you should respond, 'false' if you should stay silent. "
|
|
||||||
"Rules: "
|
|
||||||
"- Return 'true' if your name is mentioned or someone talks about you. "
|
|
||||||
"- Return 'true' if the message is related to your previous conversation. "
|
|
||||||
"- Return 'false' if the message is between other people, unclear, "
|
|
||||||
"unrelated to you, or you have nothing to add."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The latest message to evaluate.",
|
|
||||||
},
|
|
||||||
"sender_nickname": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The nickname of the person who sent the message.",
|
|
||||||
},
|
|
||||||
"recent_history": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Recent conversation history for context (last few messages).",
|
|
||||||
},
|
|
||||||
"my_name": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Your (the AI's) name.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["message", "sender_nickname", "recent_history", "my_name"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import config
|
||||||
from scripts import ntro
|
from scripts import ntro
|
||||||
|
|
||||||
WELCOME_ART = """
|
WELCOME_ART = """
|
||||||
@ -39,6 +40,12 @@ def submit(app, stdscr):
|
|||||||
return
|
return
|
||||||
|
|
||||||
log(app, "user", query)
|
log(app, "user", query)
|
||||||
|
model_info = (
|
||||||
|
config.resolve_provider(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_buffer = [""]
|
||||||
app.input_line = 0
|
app.input_line = 0
|
||||||
app.input_col = 0
|
app.input_col = 0
|
||||||
|
|||||||
@ -29,6 +29,11 @@ class HendrikTUI:
|
|||||||
self.agent_thread: threading.Thread | None = None
|
self.agent_thread: threading.Thread | None = None
|
||||||
self.agent_done = threading.Event()
|
self.agent_done = threading.Event()
|
||||||
|
|
||||||
|
def switch_model(self, item: dict):
|
||||||
|
self.llm.base_url = item["base_url"]
|
||||||
|
self.llm.model = item["model"]
|
||||||
|
self.llm.api_key = item["api_key"]
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
curses.wrapper(self._main)
|
curses.wrapper(self._main)
|
||||||
|
|||||||
109
tui/input.py
109
tui/input.py
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import curses
|
import curses
|
||||||
import os
|
import os
|
||||||
|
import config
|
||||||
from .agent import submit, log
|
from .agent import submit, log
|
||||||
|
|
||||||
|
|
||||||
@ -55,6 +56,9 @@ def handle_key(app, stdscr, key):
|
|||||||
workspace_popup(app, stdscr)
|
workspace_popup(app, stdscr)
|
||||||
elif key == 12: # Ctrl+L → clear chat log
|
elif key == 12: # Ctrl+L → clear chat log
|
||||||
app.log.clear()
|
app.log.clear()
|
||||||
|
elif key == 5: # Ctrl+E → model selector popup
|
||||||
|
if not processing:
|
||||||
|
model_selector_popup(app, stdscr)
|
||||||
|
|
||||||
# -- Enter: split logical line at cursor position --
|
# -- Enter: split logical line at cursor position --
|
||||||
elif key in (curses.KEY_ENTER, 10, 13):
|
elif key in (curses.KEY_ENTER, 10, 13):
|
||||||
@ -154,3 +158,108 @@ def workspace_popup(app, stdscr):
|
|||||||
|
|
||||||
stdscr.touchwin()
|
stdscr.touchwin()
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
|
|
||||||
|
def model_selector_popup(app, stdscr):
|
||||||
|
current_base = app.llm.base_url.rstrip("/")
|
||||||
|
current_model = app.llm.model
|
||||||
|
|
||||||
|
# Group by provider
|
||||||
|
providers: list[tuple[str, list[dict]]] = []
|
||||||
|
seen = {}
|
||||||
|
for item in config.MODELS_ITEMS:
|
||||||
|
p = item["provider"]
|
||||||
|
if p not in seen:
|
||||||
|
seen[p] = []
|
||||||
|
providers.append((p, seen[p]))
|
||||||
|
seen[p].append(item)
|
||||||
|
|
||||||
|
items = [] # (type, data, label)
|
||||||
|
selectable = [] # indices into items for model entries
|
||||||
|
current_idx = 0
|
||||||
|
|
||||||
|
for pname, plist in providers:
|
||||||
|
items.append(("header", None, pname))
|
||||||
|
for entry in plist:
|
||||||
|
idx = len(items)
|
||||||
|
items.append(("model", entry, entry["model"]))
|
||||||
|
selectable.append(idx)
|
||||||
|
if entry["base_url"] == current_base and entry["model"] == current_model:
|
||||||
|
current_idx = len(selectable) - 1
|
||||||
|
|
||||||
|
if not selectable:
|
||||||
|
return
|
||||||
|
|
||||||
|
pw = min(50, app.w - 4)
|
||||||
|
ph = min(len(items) + 4, app.h - 4)
|
||||||
|
px = (app.w - pw) // 2
|
||||||
|
py = (app.h - ph) // 2
|
||||||
|
if ph < 6:
|
||||||
|
return
|
||||||
|
|
||||||
|
win = curses.newwin(ph, pw, py, px)
|
||||||
|
win.keypad(True)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
win.erase()
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Pilih Model (Ctrl+E) ", curses.A_BOLD)
|
||||||
|
|
||||||
|
visible_h = ph - 2
|
||||||
|
total = len(items)
|
||||||
|
scroll = max(0, min(selectable[current_idx] - visible_h // 2, total - visible_h))
|
||||||
|
|
||||||
|
for i in range(visible_h):
|
||||||
|
idx = scroll + i
|
||||||
|
if idx >= total:
|
||||||
|
break
|
||||||
|
typ, data, label = items[idx]
|
||||||
|
y = 1 + i
|
||||||
|
if typ == "header":
|
||||||
|
win.addstr(y, 2, f" {label} ", curses.A_BOLD | curses.A_UNDERLINE)
|
||||||
|
else:
|
||||||
|
is_cur = (idx == selectable[current_idx])
|
||||||
|
is_active = (data["base_url"] == current_base and data["model"] == current_model)
|
||||||
|
|
||||||
|
if is_cur:
|
||||||
|
prefix = " \u25b6 " if is_active else " > "
|
||||||
|
elif is_active:
|
||||||
|
prefix = " \u2192 "
|
||||||
|
else:
|
||||||
|
prefix = " "
|
||||||
|
|
||||||
|
display = prefix + label
|
||||||
|
if len(display) > pw - 4:
|
||||||
|
display = display[:pw - 4]
|
||||||
|
attr = curses.A_REVERSE if is_cur else curses.A_NORMAL
|
||||||
|
win.addstr(y, 2, display.ljust(pw - 4), attr)
|
||||||
|
|
||||||
|
footer = " \u2191\u2193 nav \u21b5 select esc/q close "
|
||||||
|
win.addstr(ph - 1, 2, footer[:pw - 4], curses.A_DIM)
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_y = selectable[current_idx] - scroll + 1
|
||||||
|
if 0 <= target_y < ph - 1:
|
||||||
|
win.move(target_y, 4)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
win.refresh()
|
||||||
|
key = win.getch()
|
||||||
|
|
||||||
|
if key in (27, ord("q"), ord("Q")):
|
||||||
|
break
|
||||||
|
elif key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
sel_item = items[selectable[current_idx]]
|
||||||
|
if sel_item[0] == "model":
|
||||||
|
app.switch_model(sel_item[1])
|
||||||
|
break
|
||||||
|
elif key == curses.KEY_UP:
|
||||||
|
if current_idx > 0:
|
||||||
|
current_idx -= 1
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
if current_idx < len(selectable) - 1:
|
||||||
|
current_idx += 1
|
||||||
|
|
||||||
|
del win
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import curses
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import textwrap
|
import textwrap
|
||||||
|
import config
|
||||||
|
|
||||||
# -- Color pair IDs (id 1-9, id 0 = default curses) --
|
# -- Color pair IDs (id 1-9, id 0 = default curses) --
|
||||||
C_HEADER = 1 # header bar: biru
|
C_HEADER = 1 # header bar: biru
|
||||||
@ -126,6 +127,17 @@ def draw_chat(app, stdscr):
|
|||||||
_add_blank()
|
_add_blank()
|
||||||
|
|
||||||
if role == "user":
|
if role == "user":
|
||||||
|
model_info = item.get("model_info", None)
|
||||||
|
if model_info:
|
||||||
|
p_name, m_name = model_info
|
||||||
|
else:
|
||||||
|
p_name = config.resolve_provider(app.llm.base_url, app.llm.model)
|
||||||
|
m_name = app.llm.model
|
||||||
|
if p_name:
|
||||||
|
info_line = f" {p_name} - {m_name} "
|
||||||
|
else:
|
||||||
|
info_line = f" {m_name} "
|
||||||
|
_add_row([(C_SYSTEM, info_line)])
|
||||||
label = f" You ({item['time']}) "
|
label = f" You ({item['time']}) "
|
||||||
_add_row([(C_USER, label)])
|
_add_row([(C_USER, label)])
|
||||||
_wrap_render(text, indent=1, color=C_INPUT)
|
_wrap_render(text, indent=1, color=C_INPUT)
|
||||||
@ -326,7 +338,7 @@ def draw_status(app, stdscr):
|
|||||||
y = h - 9
|
y = h - 9
|
||||||
ws = os.getcwd()
|
ws = os.getcwd()
|
||||||
mode = " PROCESSING " if app.processing else " READY "
|
mode = " PROCESSING " if app.processing else " READY "
|
||||||
hints = " ^D:send ^W:workspace ^C:exit "
|
hints = " ^D:send ^E:model ^W:workspace ^C:exit "
|
||||||
max_ws = w - len(mode) - len(hints) - 4
|
max_ws = w - len(mode) - len(hints) - 4
|
||||||
if len(ws) > max_ws:
|
if len(ws) > max_ws:
|
||||||
ws = ".." + ws[-(max_ws - 2):]
|
ws = ".." + ws[-(max_ws - 2):]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user