Compare commits
3 Commits
07dd99ded4
...
b954f5a6cb
| Author | SHA1 | Date | |
|---|---|---|---|
| b954f5a6cb | |||
| 017dcd7da0 | |||
| 0bedda1c06 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
.venv
|
.venv
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
|
config.yaml
|
||||||
|
|||||||
43
config.py
43
config.py
@ -33,23 +33,28 @@ 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_ITEMS: list[dict] = []
|
||||||
_models_config = _yaml_get("models", default={})
|
_providers = _yaml_get("llm", "providers", default=[])
|
||||||
_items = _models_config.get("items", [])
|
if isinstance(_providers, list):
|
||||||
if isinstance(_items, list):
|
for prov in _providers:
|
||||||
for entry in _items:
|
if not isinstance(prov, dict):
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
continue
|
||||||
model = entry.get("model", "")
|
pname = prov.get("name", "")
|
||||||
provider = entry.get("provider", "")
|
base_url = prov.get("base_url", "").rstrip("/")
|
||||||
base_url = entry.get("base_url", "").rstrip("/")
|
api_key = prov.get("api_key", "") or llm_api_key
|
||||||
api_key = entry.get("key", "") or llm_api_key
|
models = prov.get("models", [])
|
||||||
MODELS_ITEMS.append({
|
if isinstance(models, list):
|
||||||
"model": model,
|
for m in models:
|
||||||
"provider": provider,
|
if not isinstance(m, dict):
|
||||||
"base_url": base_url,
|
continue
|
||||||
"api_key": api_key,
|
model_name = m.get("name", "")
|
||||||
"default": entry.get("default", False),
|
is_default = m.get("default", False)
|
||||||
})
|
MODELS_ITEMS.append({
|
||||||
|
"model": model_name,
|
||||||
|
"provider": pname,
|
||||||
|
"base_url": base_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"default": is_default,
|
||||||
|
})
|
||||||
|
|
||||||
def resolve_provider(base_url: str, model: str) -> str | None:
|
def resolve_provider(base_url: str, model: str) -> str | None:
|
||||||
"""Cari nama provider yg cocok dengan (base_url, model) dari MODELS_ITEMS."""
|
"""Cari nama provider yg cocok dengan (base_url, model) dari MODELS_ITEMS."""
|
||||||
@ -109,6 +114,12 @@ TELEGRAM_ALLOWED_GROUP_IDS = os.getenv("TELEGRAM_ALLOWED_GROUP_IDS", default=_ya
|
|||||||
TELEGRAM_SELECTIVE_RESPONSE = os.getenv("TELEGRAM_SELECTIVE_RESPONSE", default=str(_yaml_get("telegram", "selective_response", default="true"))).strip().lower() in ("true", "1", "yes")
|
TELEGRAM_SELECTIVE_RESPONSE = os.getenv("TELEGRAM_SELECTIVE_RESPONSE", default=str(_yaml_get("telegram", "selective_response", default="true"))).strip().lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Session (TinyDB) ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SESSION_DB_PATH = os.path.expanduser(
|
||||||
|
os.getenv("SESSION_DB_PATH", default=_yaml_get("session", "db_path", default="~/.config/hendrik/sessions.json"))
|
||||||
|
)
|
||||||
|
|
||||||
# ─── 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"))
|
||||||
|
|||||||
60
config.yaml
60
config.yaml
@ -1,60 +0,0 @@
|
|||||||
llm:
|
|
||||||
timeout: 3000 # second
|
|
||||||
|
|
||||||
agent:
|
|
||||||
max_iterations: 40 # step
|
|
||||||
max_tool_output: 40000
|
|
||||||
character: hendrik # Directory name in agent/characters/<character>/
|
|
||||||
|
|
||||||
xmpp:
|
|
||||||
enabled: false
|
|
||||||
muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server"
|
|
||||||
nickname: "" # custom MUC nickname (empty = use username)
|
|
||||||
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)
|
|
||||||
delay:
|
|
||||||
read_min: 1.0 # second
|
|
||||||
read_max: 2.0 # second
|
|
||||||
typing_speed: 15.0 # characters 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:
|
|
||||||
persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local)
|
|
||||||
59
default-config.yaml
Normal file
59
default-config.yaml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Copy and edit to `config.yaml`
|
||||||
|
|
||||||
|
agent:
|
||||||
|
character: hendrik # Directory name in agent/characters/<character>/
|
||||||
|
max_iterations: 40 # step
|
||||||
|
max_tool_output: 40000
|
||||||
|
|
||||||
|
llm:
|
||||||
|
timeout: 3000 # second
|
||||||
|
providers:
|
||||||
|
- name : "Ollama Local"
|
||||||
|
base_url : "http://localhost:11434/v1"
|
||||||
|
api_key : "ollama"
|
||||||
|
models :
|
||||||
|
- name : "granite4.1:8b"
|
||||||
|
- name : "Transformers API Local"
|
||||||
|
base_url : "http://localhost:12345/v1"
|
||||||
|
api_key : "sk-not-needed"
|
||||||
|
models :
|
||||||
|
- name : "granite4.1:8b"
|
||||||
|
- name : "Ollama Cloud"
|
||||||
|
base_url : "https://ollama.com/v1"
|
||||||
|
api_key : ""
|
||||||
|
models :
|
||||||
|
- name : "ministral-3:14b-cloud"
|
||||||
|
- name : "gemma4:31b-cloud"
|
||||||
|
default : true
|
||||||
|
- name : "OpenRouter"
|
||||||
|
base_url : "https://openrouter.ai/api/v1"
|
||||||
|
api_key : ""
|
||||||
|
models :
|
||||||
|
- name : "openrouter/owl-alpha"
|
||||||
|
- name : "nex-agi/nex-n2-pro:free"
|
||||||
|
- name : "z-ai/glm-5"
|
||||||
|
|
||||||
|
rag:
|
||||||
|
persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local)
|
||||||
|
|
||||||
|
session:
|
||||||
|
db_path: "~/.config/hendrik/sessions.json"
|
||||||
|
|
||||||
|
xmpp:
|
||||||
|
enabled: false
|
||||||
|
muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server"
|
||||||
|
nickname: "" # custom MUC nickname (empty = use username)
|
||||||
|
selective_response: true # true = only response if mentioned/relevant
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
enabled: false
|
||||||
|
allowed_group_ids: "" # comma-separated, empty = all group
|
||||||
|
selective_response: true # true = only response if mentioned/relevant
|
||||||
|
|
||||||
|
# Humanize Delay (anti-bot detection)
|
||||||
|
delay:
|
||||||
|
read_min: 1.0 # second
|
||||||
|
read_max: 2.0 # second
|
||||||
|
typing_speed: 15.0 # characters per second
|
||||||
|
typing_max: 10.0 # max typing delay limit per second
|
||||||
|
|
||||||
@ -4,3 +4,4 @@ chromadb>=0.5.0
|
|||||||
openpyxl>=3.1.0
|
openpyxl>=3.1.0
|
||||||
slixmpp
|
slixmpp
|
||||||
python-telegram-bot>=20.0
|
python-telegram-bot>=20.0
|
||||||
|
tinydb>=4.8.0
|
||||||
|
|||||||
133
services/session_manager_neo.py
Normal file
133
services/session_manager_neo.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from tinydb import TinyDB, Query
|
||||||
|
import config
|
||||||
|
|
||||||
|
DB_DIR = os.path.dirname(config.SESSION_DB_PATH)
|
||||||
|
os.makedirs(DB_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NeoSession:
|
||||||
|
session_id: str
|
||||||
|
name: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
model_info: dict
|
||||||
|
messages: list = field(default_factory=list)
|
||||||
|
doc_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NeoSessionManager:
|
||||||
|
def __init__(self, db_path: str = config.SESSION_DB_PATH):
|
||||||
|
self._db = TinyDB(db_path)
|
||||||
|
self._table = self._db.table("sessions")
|
||||||
|
|
||||||
|
def create(self, name: str, model_info: dict) -> NeoSession:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
doc = {
|
||||||
|
"session_id": str(uuid.uuid4()),
|
||||||
|
"name": name,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"model_info": model_info,
|
||||||
|
"messages": [],
|
||||||
|
}
|
||||||
|
doc_id = self._table.insert(doc)
|
||||||
|
return self._doc_to_session(self._table.get(doc_id=doc_id))
|
||||||
|
|
||||||
|
def list(self) -> list[dict]:
|
||||||
|
results = []
|
||||||
|
for doc in self._table.all():
|
||||||
|
results.append({
|
||||||
|
"doc_id": doc.doc_id,
|
||||||
|
"session_id": doc["session_id"],
|
||||||
|
"name": doc["name"],
|
||||||
|
"created_at": doc["created_at"],
|
||||||
|
"updated_at": doc["updated_at"],
|
||||||
|
"model_info": doc["model_info"],
|
||||||
|
"message_count": len(doc["messages"]),
|
||||||
|
})
|
||||||
|
return sorted(results, key=lambda x: x["updated_at"], reverse=True)
|
||||||
|
|
||||||
|
def get(self, doc_id: int) -> Optional[NeoSession]:
|
||||||
|
doc = self._table.get(doc_id=doc_id)
|
||||||
|
if doc is None:
|
||||||
|
return None
|
||||||
|
return self._doc_to_session(doc)
|
||||||
|
|
||||||
|
def rename(self, doc_id: int, new_name: str) -> bool:
|
||||||
|
if not new_name.strip():
|
||||||
|
return False
|
||||||
|
return self._table.update({"name": new_name.strip()}, doc_ids=[doc_id])
|
||||||
|
|
||||||
|
def delete(self, doc_id: int) -> bool:
|
||||||
|
return len(self._table.remove(doc_ids=[doc_id])) > 0
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[dict]:
|
||||||
|
q = Query()
|
||||||
|
results = []
|
||||||
|
for doc in self._table.search(q.name.search(query, flags=re.IGNORECASE)):
|
||||||
|
results.append({
|
||||||
|
"doc_id": doc.doc_id,
|
||||||
|
"session_id": doc["session_id"],
|
||||||
|
"name": doc["name"],
|
||||||
|
"created_at": doc["created_at"],
|
||||||
|
"updated_at": doc["updated_at"],
|
||||||
|
"model_info": doc["model_info"],
|
||||||
|
"message_count": len(doc["messages"]),
|
||||||
|
})
|
||||||
|
return sorted(results, key=lambda x: x["updated_at"], reverse=True)
|
||||||
|
|
||||||
|
def search_messages(self, query: str) -> list[dict]:
|
||||||
|
q = Query()
|
||||||
|
results = []
|
||||||
|
for doc in self._table.all():
|
||||||
|
for msg in doc["messages"]:
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if query.lower() in content.lower():
|
||||||
|
results.append({
|
||||||
|
"doc_id": doc.doc_id,
|
||||||
|
"session_name": doc["name"],
|
||||||
|
"role": msg.get("role"),
|
||||||
|
"content": content,
|
||||||
|
"session_id": doc["session_id"],
|
||||||
|
})
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
def add_message(self, doc_id: int, role: str, content: str, **kwargs) -> bool:
|
||||||
|
doc = self._table.get(doc_id=doc_id)
|
||||||
|
if doc is None:
|
||||||
|
return False
|
||||||
|
msg = {"role": role, "content": content}
|
||||||
|
msg.update(kwargs)
|
||||||
|
messages = doc["messages"]
|
||||||
|
messages.append(msg)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
return self._table.update(
|
||||||
|
{"messages": messages, "updated_at": now},
|
||||||
|
doc_ids=[doc_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_model_info(self, doc_id: int, model_info: dict) -> bool:
|
||||||
|
return self._table.update({"model_info": model_info}, doc_ids=[doc_id])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _doc_to_session(doc) -> NeoSession:
|
||||||
|
return NeoSession(
|
||||||
|
doc_id=doc.doc_id,
|
||||||
|
session_id=doc["session_id"],
|
||||||
|
name=doc["name"],
|
||||||
|
created_at=doc["created_at"],
|
||||||
|
updated_at=doc["updated_at"],
|
||||||
|
model_info=doc["model_info"],
|
||||||
|
messages=doc.get("messages", []),
|
||||||
|
)
|
||||||
33
tui/agent.py
33
tui/agent.py
@ -4,6 +4,16 @@ from datetime import datetime
|
|||||||
import config
|
import config
|
||||||
from scripts import ntro
|
from scripts import ntro
|
||||||
|
|
||||||
|
|
||||||
|
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 = """
|
WELCOME_ART = """
|
||||||
__ __ _______ __ _ ______ ______ ___ ___ _
|
__ __ _______ __ _ ______ ______ ___ ___ _
|
||||||
| | | || || | | || | | _ | | | | | | |
|
| | | || || | | || | | _ | | | | | | |
|
||||||
@ -52,7 +62,7 @@ def submit(app, stdscr):
|
|||||||
app.scroll = 999999
|
app.scroll = 999999
|
||||||
app.processing = True
|
app.processing = True
|
||||||
|
|
||||||
app.messages.append({"role": "user", "content": query})
|
_add_msg(app, "user", query)
|
||||||
|
|
||||||
app.agent_done.clear()
|
app.agent_done.clear()
|
||||||
app.agent_thread = threading.Thread(
|
app.agent_thread = threading.Thread(
|
||||||
@ -79,12 +89,7 @@ def _agent_loop(app):
|
|||||||
log(app, "system", f" {response.warning}")
|
log(app, "system", f" {response.warning}")
|
||||||
|
|
||||||
if response.tool_calls:
|
if response.tool_calls:
|
||||||
amsg = {
|
_add_msg(app, "assistant", response.content, tool_calls=response.tool_calls)
|
||||||
"role": "assistant",
|
|
||||||
"content": response.content,
|
|
||||||
"tool_calls": response.tool_calls,
|
|
||||||
}
|
|
||||||
app.messages.append(amsg)
|
|
||||||
if response.content and response.content.strip():
|
if response.content and response.content.strip():
|
||||||
log(app, "ai", response.content)
|
log(app, "ai", response.content)
|
||||||
app.scroll = 999999
|
app.scroll = 999999
|
||||||
@ -99,10 +104,7 @@ def _agent_loop(app):
|
|||||||
execute_tool(app, tc)
|
execute_tool(app, tc)
|
||||||
else:
|
else:
|
||||||
if response.content:
|
if response.content:
|
||||||
app.messages.append({
|
_add_msg(app, "assistant", response.content)
|
||||||
"role": "assistant",
|
|
||||||
"content": response.content,
|
|
||||||
})
|
|
||||||
log(app, "ai", response.content)
|
log(app, "ai", response.content)
|
||||||
log(app, "sep", "")
|
log(app, "sep", "")
|
||||||
ntro.end(stamp)
|
ntro.end(stamp)
|
||||||
@ -111,8 +113,7 @@ def _agent_loop(app):
|
|||||||
ntro.end(stamp_step)
|
ntro.end(stamp_step)
|
||||||
|
|
||||||
log(app, "error", "Max iterations reached without final answer.")
|
log(app, "error", "Max iterations reached without final answer.")
|
||||||
app.messages.append({"role": "assistant",
|
_add_msg(app, "assistant", "Max iterations reached without final answer.")
|
||||||
"content": "Max iterations reached without final answer."})
|
|
||||||
ntro.end(stamp)
|
ntro.end(stamp)
|
||||||
app.agent_done.set()
|
app.agent_done.set()
|
||||||
|
|
||||||
@ -139,8 +140,4 @@ def execute_tool(app, tool_call):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
result = f"Error executing tool: {str(e)}"
|
result = f"Error executing tool: {str(e)}"
|
||||||
|
|
||||||
app.messages.append({
|
_add_msg(app, "tool", str(result), tool_call_id=tool_call["id"])
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tool_call["id"],
|
|
||||||
"content": str(result),
|
|
||||||
})
|
|
||||||
|
|||||||
54
tui/app.py
54
tui/app.py
@ -1,9 +1,11 @@
|
|||||||
import curses
|
import curses
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
import config
|
import config
|
||||||
from .render import init_colors, draw
|
from .render import init_colors, draw
|
||||||
from .input import handle_key
|
from .input import handle_key
|
||||||
from .agent import log, WELCOME_ART
|
from .agent import log, WELCOME_ART
|
||||||
|
from services.session_manager_neo import NeoSessionManager, NeoSession
|
||||||
|
|
||||||
|
|
||||||
class HendrikTUI:
|
class HendrikTUI:
|
||||||
@ -29,10 +31,55 @@ 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()
|
||||||
|
|
||||||
|
self.session_mgr = NeoSessionManager()
|
||||||
|
self.current_session: NeoSession | None = None
|
||||||
|
|
||||||
def switch_model(self, item: dict):
|
def switch_model(self, item: dict):
|
||||||
self.llm.base_url = item["base_url"]
|
self.llm.base_url = item["base_url"]
|
||||||
self.llm.model = item["model"]
|
self.llm.model = item["model"]
|
||||||
self.llm.api_key = item["api_key"]
|
self.llm.api_key = item["api_key"]
|
||||||
|
if self.current_session:
|
||||||
|
self.session_mgr.update_model_info(
|
||||||
|
self.current_session.doc_id, self._model_info()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _model_info(self) -> dict:
|
||||||
|
return {
|
||||||
|
"provider": config.resolve_provider(self.llm.base_url, self.llm.model),
|
||||||
|
"base_url": self.llm.base_url,
|
||||||
|
"model": self.llm.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
def new_session(self):
|
||||||
|
name = f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
self.current_session = self.session_mgr.create(name, self._model_info())
|
||||||
|
self.messages = [{"role": "system", "content": 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,
|
||||||
|
)}]
|
||||||
|
self.log.clear()
|
||||||
|
self.scroll = 0
|
||||||
|
log(self, "welcome", WELCOME_ART)
|
||||||
|
|
||||||
|
def switch_session(self, doc_id: int):
|
||||||
|
session = self.session_mgr.get(doc_id)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
self.current_session = session
|
||||||
|
self.messages = list(session.messages)
|
||||||
|
self.log.clear()
|
||||||
|
self.scroll = 0
|
||||||
|
log(self, "welcome", WELCOME_ART)
|
||||||
|
for msg in session.messages:
|
||||||
|
if msg["role"] == "user":
|
||||||
|
log(self, "user", msg["content"])
|
||||||
|
elif msg["role"] == "assistant":
|
||||||
|
log(self, "ai", msg["content"])
|
||||||
|
elif msg["role"] == "system" and msg.get("content"):
|
||||||
|
if len(msg["content"]) > 100:
|
||||||
|
continue
|
||||||
|
log(self, "system", msg["content"])
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
@ -46,12 +93,7 @@ class HendrikTUI:
|
|||||||
stdscr.keypad(True)
|
stdscr.keypad(True)
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
|
|
||||||
self.messages = [{"role": "system", "content": self.build_system_prompt(
|
self.new_session()
|
||||||
tools_definition=self.tools_def,
|
|
||||||
character=config.AGENT_CHARACTER or None,
|
|
||||||
skills=config.AGENT_SKILLS.split(",") if config.AGENT_SKILLS else None,
|
|
||||||
)}]
|
|
||||||
log(self, "welcome", WELCOME_ART)
|
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
self.h, self.w = stdscr.getmaxyx()
|
self.h, self.w = stdscr.getmaxyx()
|
||||||
|
|||||||
274
tui/input.py
274
tui/input.py
@ -60,6 +60,23 @@ def handle_key(app, stdscr, key):
|
|||||||
if not processing:
|
if not processing:
|
||||||
model_selector_popup(app, stdscr)
|
model_selector_popup(app, stdscr)
|
||||||
|
|
||||||
|
# -- Session management shortcuts --
|
||||||
|
elif key == 14: # Ctrl+N → new session
|
||||||
|
if not processing:
|
||||||
|
new_session_popup(app, stdscr)
|
||||||
|
elif key == 15: # Ctrl+O → open session browser
|
||||||
|
if not processing:
|
||||||
|
session_browser_popup(app, stdscr)
|
||||||
|
elif key == 6: # Ctrl+F → search sessions
|
||||||
|
if not processing:
|
||||||
|
session_search_popup(app, stdscr)
|
||||||
|
elif key == 18: # Ctrl+R → rename current session
|
||||||
|
if not processing:
|
||||||
|
rename_popup(app, stdscr)
|
||||||
|
elif key == 24: # Ctrl+X → delete current session
|
||||||
|
if not processing:
|
||||||
|
delete_session_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):
|
||||||
line = app.input_buffer[app.input_line]
|
line = app.input_buffer[app.input_line]
|
||||||
@ -263,3 +280,260 @@ def model_selector_popup(app, stdscr):
|
|||||||
del win
|
del win
|
||||||
stdscr.touchwin()
|
stdscr.touchwin()
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def new_session_popup(app, stdscr):
|
||||||
|
if not app.current_session:
|
||||||
|
app.new_session()
|
||||||
|
return
|
||||||
|
pw = min(50, app.w - 4)
|
||||||
|
ph = 3
|
||||||
|
px = (app.w - pw) // 2
|
||||||
|
py = app.h // 2 - 1
|
||||||
|
|
||||||
|
win = curses.newwin(ph, pw, py, px)
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Start new session?", curses.A_BOLD)
|
||||||
|
win.addstr(1, 2, " [Y]es [N]o ")
|
||||||
|
|
||||||
|
win.refresh()
|
||||||
|
while True:
|
||||||
|
key = win.getch()
|
||||||
|
if key in (ord("y"), ord("Y")):
|
||||||
|
app.new_session()
|
||||||
|
break
|
||||||
|
elif key in (ord("n"), ord("N"), 27):
|
||||||
|
break
|
||||||
|
del win
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def session_browser_popup(app, stdscr):
|
||||||
|
sessions = app.session_mgr.list()
|
||||||
|
if not sessions:
|
||||||
|
curses.flash()
|
||||||
|
return
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for s in sessions:
|
||||||
|
dt = s["updated_at"][:16].replace("T", " ")
|
||||||
|
label = f"{s['name']} ({dt}, {s['message_count']} msg)"
|
||||||
|
items.append((s["doc_id"], label))
|
||||||
|
|
||||||
|
pw = min(60, 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)
|
||||||
|
|
||||||
|
current_idx = 0
|
||||||
|
while True:
|
||||||
|
win.erase()
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Sessions (\u2191\u2193 nav \u21b5 select D delete esc close)",
|
||||||
|
curses.A_BOLD)
|
||||||
|
|
||||||
|
visible_h = ph - 2
|
||||||
|
total = len(items)
|
||||||
|
scroll = max(0, min(current_idx - visible_h // 2, total - visible_h))
|
||||||
|
|
||||||
|
for i in range(visible_h):
|
||||||
|
idx = scroll + i
|
||||||
|
if idx >= total:
|
||||||
|
break
|
||||||
|
doc_id, label = items[idx]
|
||||||
|
y = 1 + i
|
||||||
|
is_cur = (idx == current_idx)
|
||||||
|
is_active = (app.current_session and doc_id == app.current_session.doc_id)
|
||||||
|
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)
|
||||||
|
|
||||||
|
win.refresh()
|
||||||
|
key = win.getch()
|
||||||
|
|
||||||
|
if key in (27, ord("q"), ord("Q")):
|
||||||
|
break
|
||||||
|
elif key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
doc_id, _ = items[current_idx]
|
||||||
|
if not app.current_session or doc_id != app.current_session.doc_id:
|
||||||
|
app.switch_session(doc_id)
|
||||||
|
break
|
||||||
|
elif key == curses.KEY_UP:
|
||||||
|
if current_idx > 0:
|
||||||
|
current_idx -= 1
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
if current_idx < len(items) - 1:
|
||||||
|
current_idx += 1
|
||||||
|
elif key in (ord("d"), ord("D")):
|
||||||
|
doc_id, label = items[current_idx]
|
||||||
|
if app.current_session and doc_id == app.current_session.doc_id:
|
||||||
|
continue
|
||||||
|
app.session_mgr.delete(doc_id)
|
||||||
|
sessions = app.session_mgr.list()
|
||||||
|
items = [(s["doc_id"],
|
||||||
|
f"{s['name']} ({s['updated_at'][:16].replace('T', ' ')}, {s['message_count']} msg)")
|
||||||
|
for s in sessions]
|
||||||
|
current_idx = min(current_idx, len(items) - 1)
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
|
||||||
|
del win
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def session_search_popup(app, stdscr):
|
||||||
|
pw = min(60, app.w - 4)
|
||||||
|
ph = 3
|
||||||
|
px = (app.w - pw) // 2
|
||||||
|
py = app.h // 2 - 1
|
||||||
|
|
||||||
|
win = curses.newwin(ph, pw, py, px)
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Search sessions: ")
|
||||||
|
win.addstr(1, 2, " " * (pw - 4))
|
||||||
|
|
||||||
|
curses.echo()
|
||||||
|
query = win.getstr(1, 2, pw - 5).decode("utf-8")
|
||||||
|
curses.noecho()
|
||||||
|
del win
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
query = query.strip()
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
|
||||||
|
results = app.session_mgr.search(query)
|
||||||
|
if not results:
|
||||||
|
log(app, "system", f"No sessions matching: {query}")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for s in results:
|
||||||
|
dt = s["updated_at"][:16].replace("T", " ")
|
||||||
|
label = f"{s['name']} ({dt}, {s['message_count']} msg)"
|
||||||
|
items.append((s["doc_id"], label))
|
||||||
|
|
||||||
|
pw = min(60, 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)
|
||||||
|
|
||||||
|
current_idx = 0
|
||||||
|
while True:
|
||||||
|
win.erase()
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, f" Results for \"{query}\" (\u2191\u2193 nav \u21b5 select esc close)",
|
||||||
|
curses.A_BOLD)
|
||||||
|
|
||||||
|
visible_h = ph - 2
|
||||||
|
total = len(items)
|
||||||
|
scroll = max(0, min(current_idx - visible_h // 2, total - visible_h))
|
||||||
|
|
||||||
|
for i in range(visible_h):
|
||||||
|
idx = scroll + i
|
||||||
|
if idx >= total:
|
||||||
|
break
|
||||||
|
doc_id, label = items[idx]
|
||||||
|
y = 1 + i
|
||||||
|
is_cur = (idx == current_idx)
|
||||||
|
attr = curses.A_REVERSE if is_cur else curses.A_NORMAL
|
||||||
|
win.addstr(y, 2, f" {label}"[:pw - 4].ljust(pw - 4), attr)
|
||||||
|
|
||||||
|
win.refresh()
|
||||||
|
key = win.getch()
|
||||||
|
|
||||||
|
if key in (27, ord("q"), ord("Q")):
|
||||||
|
break
|
||||||
|
elif key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
doc_id, _ = items[current_idx]
|
||||||
|
if not app.current_session or doc_id != app.current_session.doc_id:
|
||||||
|
app.switch_session(doc_id)
|
||||||
|
break
|
||||||
|
elif key == curses.KEY_UP:
|
||||||
|
if current_idx > 0:
|
||||||
|
current_idx -= 1
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
if current_idx < len(items) - 1:
|
||||||
|
current_idx += 1
|
||||||
|
|
||||||
|
del win
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def rename_popup(app, stdscr):
|
||||||
|
if not app.current_session:
|
||||||
|
return
|
||||||
|
pw = min(60, app.w - 4)
|
||||||
|
ph = 3
|
||||||
|
px = (app.w - pw) // 2
|
||||||
|
py = app.h // 2 - 1
|
||||||
|
|
||||||
|
win = curses.newwin(ph, pw, py, px)
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Rename session: ")
|
||||||
|
win.addstr(1, 2, " " * (pw - 4))
|
||||||
|
|
||||||
|
curses.echo()
|
||||||
|
new_name = win.getstr(1, 2, pw - 5).decode("utf-8")
|
||||||
|
curses.noecho()
|
||||||
|
del win
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
new_name = new_name.strip()
|
||||||
|
if new_name:
|
||||||
|
app.session_mgr.rename(app.current_session.doc_id, new_name)
|
||||||
|
app.current_session.name = new_name
|
||||||
|
|
||||||
|
|
||||||
|
def delete_session_popup(app, stdscr):
|
||||||
|
if not app.current_session:
|
||||||
|
return
|
||||||
|
pw = min(50, app.w - 4)
|
||||||
|
ph = 4
|
||||||
|
px = (app.w - pw) // 2
|
||||||
|
py = app.h // 2 - 1
|
||||||
|
|
||||||
|
win = curses.newwin(ph, pw, py, px)
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Delete current session?", curses.A_BOLD)
|
||||||
|
name = app.current_session.name
|
||||||
|
display = name if len(name) <= pw - 6 else name[:pw - 9] + "..."
|
||||||
|
win.addstr(1, 2, f" \"{display}\"")
|
||||||
|
win.addstr(2, 2, " [Y]es [N]o ")
|
||||||
|
|
||||||
|
win.refresh()
|
||||||
|
while True:
|
||||||
|
key = win.getch()
|
||||||
|
if key in (ord("y"), ord("Y")):
|
||||||
|
doc_id = app.current_session.doc_id
|
||||||
|
app.session_mgr.delete(doc_id)
|
||||||
|
app.new_session()
|
||||||
|
break
|
||||||
|
elif key in (ord("n"), ord("N"), 27):
|
||||||
|
break
|
||||||
|
del win
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
|
|||||||
@ -333,27 +333,46 @@ def draw_input(app, stdscr):
|
|||||||
|
|
||||||
|
|
||||||
def draw_status(app, stdscr):
|
def draw_status(app, stdscr):
|
||||||
# Status bar di baris h-9: workspace, mode (READY/PROCESSING), shortcut hints
|
# Status bar di baris h-9: [session] workspace, mode (READY/PROCESSING), shortcut hints
|
||||||
h, w = app.h, app.w
|
h, w = app.h, app.w
|
||||||
y = h - 9
|
y = h - 9
|
||||||
ws = os.getcwd()
|
ws = os.getcwd()
|
||||||
mode = " PROCESSING " if app.processing else " READY "
|
|
||||||
hints = " ^D:send ^E:model ^W:workspace ^C:exit "
|
|
||||||
max_ws = w - len(mode) - len(hints) - 4
|
|
||||||
if len(ws) > max_ws:
|
|
||||||
ws = ".." + ws[-(max_ws - 2):]
|
|
||||||
|
|
||||||
status = (f" {ws} \u2502{mode}\u2502{hints}").ljust(w)[:w]
|
session_tag = ""
|
||||||
|
if app.current_session:
|
||||||
|
sname = app.current_session.name
|
||||||
|
if len(sname) > 20:
|
||||||
|
sname = sname[:17] + "..."
|
||||||
|
session_tag = f"[{sname}] "
|
||||||
|
|
||||||
|
mode = " PROCESSING " if app.processing else " READY "
|
||||||
|
hints = " ^N:new ^O:open ^R:rename ^D:send ^E:model ^W:ws ^C:exit "
|
||||||
|
max_left = w - len(mode) - len(hints) - 4
|
||||||
|
left = session_tag + ws
|
||||||
|
if len(left) > max_left:
|
||||||
|
left = ".." + left[-(max_left - 2):]
|
||||||
|
|
||||||
|
status = (f" {left} \u2502{mode}\u2502{hints}").ljust(w)[:w]
|
||||||
stdscr.addstr(y, 0, status, curses.color_pair(C_STATUS_INFO))
|
stdscr.addstr(y, 0, status, curses.color_pair(C_STATUS_INFO))
|
||||||
|
|
||||||
# Highlight mode dengan warna berbeda
|
# Highlight mode dengan warna berbeda
|
||||||
mode_start = len(f" {ws} \u2502")
|
mode_start = len(f" {left} \u2502")
|
||||||
mode_end = mode_start + len(mode)
|
mode_end = mode_start + len(mode)
|
||||||
mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC)
|
mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC)
|
||||||
stdscr.addstr(y, mode_start, mode, mode_attr | curses.A_BOLD)
|
stdscr.addstr(y, mode_start, mode, mode_attr | curses.A_BOLD)
|
||||||
|
|
||||||
# ^D:send — abu-abu saat processing, bold putih saat idle
|
# Hint shortcuts di kanan — tombol tertentu abu-abu saat processing
|
||||||
if app.processing:
|
x = mode_end + 2
|
||||||
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_HINT_DISABLED))
|
hints_parts = [
|
||||||
else:
|
("^N:new", True),
|
||||||
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_STATUS_INFO) | curses.A_BOLD)
|
(" ^O:open", True),
|
||||||
|
(" ^R:rename", True),
|
||||||
|
(" ^D:send", not app.processing),
|
||||||
|
(" ^E:model", True),
|
||||||
|
(" ^W:ws", True),
|
||||||
|
(" ^C:exit", True),
|
||||||
|
]
|
||||||
|
for text, enabled in hints_parts:
|
||||||
|
attr = curses.color_pair(C_STATUS_INFO) | curses.A_BOLD if enabled else curses.color_pair(C_HINT_DISABLED)
|
||||||
|
stdscr.addstr(y, x, text, attr)
|
||||||
|
x += len(text)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user