diff --git a/config.py b/config.py index 24f938c..b34c889 100644 --- a/config.py +++ b/config.py @@ -114,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") +# ─── 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_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default=_yaml_get("rag", "persist_dir", default="chroma_db")) diff --git a/default-config.yaml b/default-config.yaml index 06900f0..00baf9f 100644 --- a/default-config.yaml +++ b/default-config.yaml @@ -36,6 +36,9 @@ llm: 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" diff --git a/requirements.txt b/requirements.txt index ab857cd..9efd00b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ chromadb>=0.5.0 openpyxl>=3.1.0 slixmpp python-telegram-bot>=20.0 +tinydb>=4.8.0 diff --git a/services/session_manager_neo.py b/services/session_manager_neo.py new file mode 100644 index 0000000..c4f3e50 --- /dev/null +++ b/services/session_manager_neo.py @@ -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", []), + ) diff --git a/tui/agent.py b/tui/agent.py index f726bcc..79772dd 100644 --- a/tui/agent.py +++ b/tui/agent.py @@ -4,6 +4,16 @@ from datetime import datetime import config 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 = """ __ __ _______ __ _ ______ ______ ___ ___ _ | | | || || | | || | | _ | | | | | | | @@ -52,7 +62,7 @@ def submit(app, stdscr): app.scroll = 999999 app.processing = True - app.messages.append({"role": "user", "content": query}) + _add_msg(app, "user", query) app.agent_done.clear() app.agent_thread = threading.Thread( @@ -79,12 +89,7 @@ def _agent_loop(app): log(app, "system", f" {response.warning}") if response.tool_calls: - amsg = { - "role": "assistant", - "content": response.content, - "tool_calls": response.tool_calls, - } - app.messages.append(amsg) + _add_msg(app, "assistant", response.content, tool_calls=response.tool_calls) if response.content and response.content.strip(): log(app, "ai", response.content) app.scroll = 999999 @@ -99,10 +104,7 @@ def _agent_loop(app): execute_tool(app, tc) else: if response.content: - app.messages.append({ - "role": "assistant", - "content": response.content, - }) + _add_msg(app, "assistant", response.content) log(app, "ai", response.content) log(app, "sep", "") ntro.end(stamp) @@ -111,8 +113,7 @@ def _agent_loop(app): ntro.end(stamp_step) log(app, "error", "Max iterations reached without final answer.") - app.messages.append({"role": "assistant", - "content": "Max iterations reached without final answer."}) + _add_msg(app, "assistant", "Max iterations reached without final answer.") ntro.end(stamp) app.agent_done.set() @@ -139,8 +140,4 @@ def execute_tool(app, tool_call): except Exception as e: result = f"Error executing tool: {str(e)}" - app.messages.append({ - "role": "tool", - "tool_call_id": tool_call["id"], - "content": str(result), - }) + _add_msg(app, "tool", str(result), tool_call_id=tool_call["id"]) diff --git a/tui/app.py b/tui/app.py index 80b4843..97aa8c2 100644 --- a/tui/app.py +++ b/tui/app.py @@ -1,9 +1,11 @@ import curses import threading +from datetime import datetime import config from .render import init_colors, draw from .input import handle_key from .agent import log, WELCOME_ART +from services.session_manager_neo import NeoSessionManager, NeoSession class HendrikTUI: @@ -29,10 +31,55 @@ class HendrikTUI: self.agent_thread: threading.Thread | None = None self.agent_done = threading.Event() + self.session_mgr = NeoSessionManager() + self.current_session: NeoSession | None = None + 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"] + 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): try: @@ -46,12 +93,7 @@ class HendrikTUI: stdscr.keypad(True) stdscr.refresh() - 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, - )}] - log(self, "welcome", WELCOME_ART) + self.new_session() while self.running: self.h, self.w = stdscr.getmaxyx() diff --git a/tui/input.py b/tui/input.py index 4304fc8..4d46814 100644 --- a/tui/input.py +++ b/tui/input.py @@ -60,6 +60,23 @@ def handle_key(app, stdscr, key): if not processing: 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 -- elif key in (curses.KEY_ENTER, 10, 13): line = app.input_buffer[app.input_line] @@ -263,3 +280,260 @@ def model_selector_popup(app, stdscr): del win stdscr.touchwin() 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() diff --git a/tui/render.py b/tui/render.py index 2438a42..a7640cf 100644 --- a/tui/render.py +++ b/tui/render.py @@ -333,27 +333,46 @@ def draw_input(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 y = h - 9 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)) # Highlight mode dengan warna berbeda - mode_start = len(f" {ws} \u2502") + mode_start = len(f" {left} \u2502") 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) stdscr.addstr(y, mode_start, mode, mode_attr | curses.A_BOLD) - # ^D:send — abu-abu saat processing, bold putih saat idle - if app.processing: - stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_HINT_DISABLED)) - else: - stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_STATUS_INFO) | curses.A_BOLD) + # Hint shortcuts di kanan — tombol tertentu abu-abu saat processing + x = mode_end + 2 + hints_parts = [ + ("^N:new", True), + (" ^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)