From 1677fcac439ced8bd73a9580686da21421fd27b6 Mon Sep 17 00:00:00 2001 From: Eskimue Date: Tue, 21 Jul 2026 15:50:32 +0200 Subject: [PATCH] Fix Hermes LLM GUI startup and layout --- hermes_llm/hermes_llm_gui.py | 222 +++++++++++++++++++++++++++++------ 1 file changed, 183 insertions(+), 39 deletions(-) diff --git a/hermes_llm/hermes_llm_gui.py b/hermes_llm/hermes_llm_gui.py index b64f3fb..c719a3b 100644 --- a/hermes_llm/hermes_llm_gui.py +++ b/hermes_llm/hermes_llm_gui.py @@ -197,18 +197,21 @@ class HermesLLMGUI: body = ttk.PanedWindow(self.root, orient=tk.VERTICAL) body.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6)) - upper = ttk.Frame(body, padding=2) + top_area = ttk.PanedWindow(body, orient=tk.VERTICAL) + body.add(top_area, weight=5) + + upper = ttk.Frame(top_area, padding=2) upper.columnconfigure(0, weight=1) upper.columnconfigure(1, weight=1) - body.add(upper, weight=3) + top_area.add(upper, weight=4) self._build_llm_panel(upper) self._build_hermes_panel(upper) - lower = ttk.Frame(body, padding=2) + lower = ttk.Frame(top_area, padding=2) lower.columnconfigure(0, weight=1) lower.rowconfigure(0, weight=1) - body.add(lower, weight=2) + top_area.add(lower, weight=3) lower_tabs = ttk.Notebook(lower) lower_tabs.grid(row=0, column=0, sticky=tk.NSEW) @@ -216,27 +219,31 @@ class HermesLLMGUI: actions_tab = ttk.Frame(lower_tabs, padding=4) chat_tab = ttk.Frame(lower_tabs, padding=4) lower_tabs.add(actions_tab, text="Actions") - lower_tabs.add(chat_tab, text="Hermes Chat") + lower_tabs.add(chat_tab, text="Chat") self._build_actions(actions_tab) self._build_chat_tab(chat_tab) - log_frame = ttk.LabelFrame(self.root, text="Output", padding=4) - log_frame.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6)) + log_frame = ttk.LabelFrame(body, text="Output", padding=4) + body.add(log_frame, weight=4) + log_frame.columnconfigure(0, weight=1) + log_frame.rowconfigure(0, weight=1) self.log = scrolledtext.ScrolledText( log_frame, - font=("Consolas", 9), + font=("Consolas", 10), bg="#1e1e1e", fg="#d4d4d4", insertbackground="white", wrap=tk.WORD, state=tk.DISABLED, + height=18, ) - self.log.pack(fill=tk.BOTH, expand=True) + self.log.grid(row=0, column=0, sticky=tk.NSEW) self.log.tag_config("error", foreground="#f48771") self.log.tag_config("ok", foreground="#89d185") self.log.tag_config("info", foreground="#9cdcfe") + self.root.after(100, lambda: self._set_initial_pane_sizes(body, top_area)) def _build_connection_bar(self, parent): frame = ttk.LabelFrame(parent, text="SSH connection", padding=6) @@ -292,15 +299,20 @@ class HermesLLMGUI: ttk.Label(frame, text="Status:").grid(row=len(rows), column=0, sticky=tk.W, padx=4, pady=(8, 3)) self.llm_status_label = ttk.Label(frame, textvariable=self.v_llm_status, foreground="#555") self.llm_status_label.grid(row=len(rows), column=1, sticky=tk.W, padx=4, pady=(8, 3)) + self.llm_status_label.bind("", lambda _event: self._copy_status("LLM", self.v_llm_status.get())) buttons = ttk.Frame(frame) buttons.grid(row=len(rows) + 1, column=0, columnspan=3, sticky=tk.W, padx=4, pady=(8, 0)) - tip(ttk.Button(buttons, text="Start LLM", command=self._start_llm), "Starts the remote llama-server process.").pack( - side=tk.LEFT, padx=(0, 6) + self.start_llm_button = tip( + ttk.Button(buttons, text="Start LLM", command=self._start_llm), + "Starts the remote llama-server process.", ) - tip(ttk.Button(buttons, text="Stop LLM", command=self._stop_llm), "Stops the remote llama-server process.").pack( - side=tk.LEFT, padx=6 + self.start_llm_button.pack(side=tk.LEFT, padx=(0, 6)) + self.stop_llm_button = tip( + ttk.Button(buttons, text="Stop LLM", command=self._stop_llm), + "Stops the remote llama-server process.", ) + self.stop_llm_button.pack(side=tk.LEFT, padx=6) tip( ttk.Button(buttons, text="LLM status", command=self._refresh_llm_status), "Checks the current process state and whether the HTTP endpoint is healthy.", @@ -342,6 +354,10 @@ class HermesLLMGUI: ttk.Label(frame, text="Status:").grid(row=len(rows) + 1, column=0, sticky=tk.W, padx=4, pady=(8, 3)) self.hermes_status_label = ttk.Label(frame, textvariable=self.v_hermes_status, foreground="#555") self.hermes_status_label.grid(row=len(rows) + 1, column=1, sticky=tk.W, padx=4, pady=(8, 3)) + self.hermes_status_label.bind( + "", + lambda _event: self._copy_status("Hermes", self.v_hermes_status.get()), + ) buttons = ttk.Frame(frame) buttons.grid(row=len(rows) + 2, column=0, columnspan=3, sticky=tk.W, padx=4, pady=(8, 0)) @@ -425,13 +441,18 @@ class HermesLLMGUI: buttons = ttk.Frame(parent) buttons.grid(row=1, column=0, sticky=tk.W, pady=(0, 6)) tip( - ttk.Button(buttons, text="Send to Hermes", command=self._send_chat_message), + ttk.Button(buttons, text="Send", command=self._send_chat_message), "Sends the current chat input using the selected mode.", ).pack(side=tk.LEFT, padx=(0, 6)) tip( - ttk.Button(buttons, text="Clear chat", command=self._clear_chat_history), - "Clears only the chat tab history in this GUI.", + ttk.Button(buttons, text="New chat", command=self._clear_chat_history), + "Starts a new local chat session in this GUI.", ).pack(side=tk.LEFT, padx=6) + ttk.Label( + buttons, + text="Enter = senden, Shift+Enter = Zeilenumbruch", + foreground="#555", + ).pack(side=tk.LEFT, padx=(12, 0)) history_frame = ttk.LabelFrame(parent, text="Conversation", padding=4) history_frame.grid(row=2, column=0, sticky=tk.NSEW, pady=(0, 6)) @@ -446,21 +467,36 @@ class HermesLLMGUI: height=12, ) self.chat_text.grid(row=0, column=0, sticky=tk.NSEW) + self.chat_text.tag_config("chat_user", foreground="#0b57d0", spacing1=8, spacing3=10) + self.chat_text.tag_config("chat_assistant", foreground="#188038", spacing1=8, spacing3=10) + self.chat_text.tag_config("chat_error", foreground="#c5221f", spacing1=8, spacing3=10) + self.chat_text.tag_config("chat_meta", foreground="#666666", spacing1=4, spacing3=8) input_frame = ttk.Frame(parent) input_frame.grid(row=3, column=0, sticky=tk.EW) input_frame.columnconfigure(0, weight=1) - entry = ttk.Entry(input_frame, textvariable=self.v_chat_input) - entry.grid(row=0, column=0, sticky=tk.EW, padx=(0, 6)) - entry.bind("", lambda _event: self._send_chat_message()) - tip( + self.chat_input = tk.Text(input_frame, height=4, wrap=tk.WORD, font=("Segoe UI", 10)) + self.chat_input.grid(row=0, column=0, sticky=tk.EW, padx=(0, 6)) + self.chat_input.bind("", self._on_chat_return) + self.chat_input.bind("", self._on_chat_shift_return) + self.chat_send_button = tip( ttk.Button(input_frame, text="Send", command=self._send_chat_message), - "Submits the current message to Hermes.", - ).grid(row=0, column=1) + "Submits the current message using the selected chat mode.", + ) + self.chat_send_button.grid(row=0, column=1, sticky=tk.N) + self._append_chat_meta("Neuer Chat gestartet.") def _submit(self, coro): return asyncio.run_coroutine_threadsafe(coro, self._loop) + def _set_initial_pane_sizes(self, outer_pane: ttk.PanedWindow, top_pane: ttk.PanedWindow): + try: + total = max(self.root.winfo_height(), 760) + outer_pane.sashpos(0, int(total * 0.62)) + top_pane.sashpos(0, int(total * 0.36)) + except Exception: + pass + def _make_ssh(self) -> AsyncSSH: return AsyncSSH( self.v_host.get().strip(), @@ -533,21 +569,29 @@ class HermesLLMGUI: return f""" set -e mkdir -p {shlex.quote(os.path.dirname(log_path))} {shlex.quote(os.path.dirname(pid_path))} -pkill -f "llama-server.*{model}" || true +if [ -f {shlex.quote(pid_path)} ]; then + oldpid=$(cat {shlex.quote(pid_path)}) + if ps -p "$oldpid" > /dev/null 2>&1; then + kill "$oldpid" 2>/dev/null || true + sleep 1 + fi + rm -f {shlex.quote(pid_path)} +fi +pkill -x llama-server 2>/dev/null || true cd {shlex.quote(workdir)} -nohup ./build/bin/llama-server \\ +setsid nohup ./build/bin/llama-server \\ -m {shlex.quote(model)} \\ -a {shlex.quote(alias)} \\ -ngl 0 -t {threads} -c {ctx} --host {shlex.quote(bind_host)} --port {port} \\ - > {shlex.quote(log_path)} 2>&1 & -echo $! > {shlex.quote(pid_path)} + < /dev/null >> {shlex.quote(log_path)} 2>&1 & +pid=$! +echo "$pid" > {shlex.quote(pid_path)} sleep 3 cat {shlex.quote(pid_path)} """ def _llm_stop_script(self) -> str: pid_path = self.v_llm_pid.get().strip() - model = self.v_llm_model.get().strip() return f""" set -e if [ -f {shlex.quote(pid_path)} ]; then @@ -555,12 +599,13 @@ if [ -f {shlex.quote(pid_path)} ]; then kill "$pid" 2>/dev/null || true rm -f {shlex.quote(pid_path)} fi -pkill -f "llama-server.*{model}" || true +pkill -x llama-server 2>/dev/null || true """ def _llm_status_script(self) -> str: pid_path = self.v_llm_pid.get().strip() port = int(self.v_llm_port.get()) + host = self.v_host.get().strip() return f""" if [ -f {shlex.quote(pid_path)} ]; then pid=$(cat {shlex.quote(pid_path)}) @@ -568,13 +613,14 @@ if [ -f {shlex.quote(pid_path)} ]; then echo "running: pid=$pid" ps -p "$pid" -o pid=,etime=,cmd= else - echo "stale pid file: $pid" + rm -f {shlex.quote(pid_path)} + echo "not running (removed stale pid: $pid)" fi else echo "not running" fi printf '%s\\n' '--- health ---' -curl -s http://127.0.0.1:{port}/health || true +curl -s http://{host}:{port}/health || true """ def _hermes_start_script(self) -> str: @@ -594,8 +640,13 @@ if [ -f {shlex.quote(pid_path)} ]; then rm -f {shlex.quote(pid_path)} fi . {shlex.quote(posixpath.join(venv, "bin", "activate"))} -nohup hermes serve --host {shlex.quote(bind_host)} --port {port}{extra_suffix} > {shlex.quote(log_path)} 2>&1 & -echo $! > {shlex.quote(pid_path)} +setsid bash -lc {shlex.quote( + f". {shlex.quote(posixpath.join(venv, 'bin', 'activate'))} && " + + f"hermes serve --host {shlex.quote(bind_host)} --port {port}{extra_suffix} " + + f"< /dev/null >> {shlex.quote(log_path)} 2>&1" +)} & +pid=$! +echo "$pid" > {shlex.quote(pid_path)} sleep 3 cat {shlex.quote(pid_path)} """ @@ -634,11 +685,12 @@ curl -sI http://127.0.0.1:{port} | head -1 || true def _start_llm(self): self._log("[info] starting remote llama.cpp server\n", "info") - self._submit(self._async_run_script(self._llm_start_script(), "LLM started", self._refresh_llm_status)) + self._set_llm_starting(True) + self._submit(self._async_start_llm()) def _stop_llm(self): self._log("[info] stopping remote llama.cpp server\n", "info") - self._submit(self._async_run_script(self._llm_stop_script(), "LLM stopped", self._refresh_llm_status)) + self._submit(self._async_stop_llm()) def _start_hermes(self): self._log("[info] starting remote Hermes backend\n", "info") @@ -658,14 +710,14 @@ curl -sI http://127.0.0.1:{port} | head -1 || true async def _async_start_both(self): self._log("[info] starting LLM first\n", "info") - await self._async_run_script(self._llm_start_script(), "LLM started") + await self._async_start_llm() self._log("[info] starting Hermes next\n", "info") await self._async_run_script(self._hermes_start_script(), "Hermes started") self.root.after(0, self._refresh_all_status) async def _async_stop_both(self): await self._async_run_script(self._hermes_stop_script(), "Hermes stopped") - await self._async_run_script(self._llm_stop_script(), "LLM stopped") + await self._async_stop_llm() self.root.after(0, self._refresh_all_status) def _refresh_llm_status(self): @@ -697,6 +749,47 @@ curl -sI http://127.0.0.1:{port} | head -1 || true finally: await ssh.close() + async def _async_start_llm(self): + ssh = self._make_ssh() + try: + await ssh.connect() + result = await ssh.exec(f"bash -lc {shlex.quote(self._llm_start_script())}") + if result.strip(): + self._log(result if result.endswith("\n") else result + "\n", "info") + + llm_port = int(self.v_llm_port.get()) + llm_host = self.v_host.get().strip() + health_cmd = "bash -lc " + shlex.quote(f"curl -s http://{llm_host}:{llm_port}/health") + + for _ in range(20): + health_output = await ssh.exec(health_cmd) + if '"status":"ok"' in health_output.replace(" ", ""): + self._log(f"[ok] LLM started and healthy on {llm_host}:{llm_port}\n", "ok") + self.root.after(0, lambda: self._set_llm_status("running")) + return + await asyncio.sleep(1) + + self._log( + f"[error] LLM start command ran, but no healthy endpoint appeared on {llm_host}:{llm_port}\n", + "error", + ) + log_output = await ssh.exec( + f"bash -lc {shlex.quote(f'tail -n 60 {shlex.quote(self.v_llm_log.get().strip())}')}" + ) + if log_output.strip(): + self._log(log_output if log_output.endswith("\n") else log_output + "\n", "error") + self.root.after(0, lambda: self._set_llm_status("not running")) + except Exception as exc: + self._log(f"[error] LLM start failed: {exc}\n", "error") + self.root.after(0, lambda: self._set_llm_status("not running")) + finally: + await ssh.close() + self.root.after(0, lambda: self._set_llm_starting(False)) + self.root.after(0, self._refresh_llm_status) + + async def _async_stop_llm(self): + await self._async_run_script(self._llm_stop_script(), "LLM stopped", self._refresh_llm_status) + def _tail_llm_log(self): self._submit(self._async_tail_log(self.v_llm_log.get().strip(), "LLM")) @@ -723,10 +816,13 @@ curl -sI http://127.0.0.1:{port} | head -1 || true self._submit(self._async_test_chat()) def _send_chat_message(self): - text = self.v_chat_input.get().strip() + if hasattr(self, "chat_send_button") and str(self.chat_send_button["state"]) == "disabled": + return + text = self.chat_input.get("1.0", tk.END).strip() if not text: return - self.v_chat_input.set("") + self.chat_input.delete("1.0", tk.END) + self._set_chat_busy(True) self._append_chat_message("You", text) self._chat_history.append(("user", text)) if self.v_chat_mode.get() == "hermes": @@ -739,13 +835,50 @@ curl -sI http://127.0.0.1:{port} | head -1 || true self.chat_text.config(state=tk.NORMAL) self.chat_text.delete("1.0", tk.END) self.chat_text.config(state=tk.DISABLED) + if hasattr(self, "chat_input"): + self.chat_input.delete("1.0", tk.END) + self._append_chat_meta("Neuer Chat gestartet.") def _append_chat_message(self, speaker: str, text: str): + tag = "chat_assistant" + if speaker == "You": + tag = "chat_user" + elif "Error" in speaker: + tag = "chat_error" self.chat_text.config(state=tk.NORMAL) - self.chat_text.insert(tk.END, f"{speaker}:\n{text.strip()}\n\n") + self.chat_text.insert(tk.END, f"{speaker}:\n", tag) + self.chat_text.insert(tk.END, f"{text.strip()}\n\n") self.chat_text.see(tk.END) self.chat_text.config(state=tk.DISABLED) + def _append_chat_meta(self, text: str): + self.chat_text.config(state=tk.NORMAL) + self.chat_text.insert(tk.END, f"{text.strip()}\n\n", "chat_meta") + self.chat_text.see(tk.END) + self.chat_text.config(state=tk.DISABLED) + + def _copy_status(self, label: str, value: str): + text = f"{label} status: {value or 'unknown'}" + self.root.clipboard_clear() + self.root.clipboard_append(text) + self._log(f"[info] copied to clipboard: {text}\n", "info") + + def _set_chat_busy(self, busy: bool): + if hasattr(self, "chat_send_button"): + self.chat_send_button.configure(state=tk.DISABLED if busy else tk.NORMAL) + if hasattr(self, "chat_input"): + self.chat_input.configure(state=tk.DISABLED if busy else tk.NORMAL) + if not busy: + self.chat_input.focus_set() + + def _on_chat_return(self, _event): + self._send_chat_message() + return "break" + + def _on_chat_shift_return(self, _event): + self.chat_input.insert(tk.INSERT, "\n") + return "break" + def _set_llm_status(self, text: str): self.v_llm_status.set(text) color = "#555" @@ -756,6 +889,15 @@ curl -sI http://127.0.0.1:{port} | head -1 || true color = "#c5221f" self.llm_status_label.configure(foreground=color) + def _set_llm_starting(self, starting: bool): + if hasattr(self, "start_llm_button"): + self.start_llm_button.configure( + text="Starting..." if starting else "Start LLM", + state=tk.DISABLED if starting else tk.NORMAL, + ) + if hasattr(self, "stop_llm_button"): + self.stop_llm_button.configure(state=tk.DISABLED if starting else tk.NORMAL) + def _set_hermes_status(self, text: str): self.v_hermes_status.set(text) color = "#555" @@ -917,6 +1059,7 @@ curl -sI http://127.0.0.1:{port} | head -1 || true self._log(f"[error] Hermes chat failed: {exc}\n", "error") finally: await ssh.close() + self.root.after(0, lambda: self._set_chat_busy(False)) async def _async_send_direct_llm_chat_message(self): ssh = self._make_ssh() @@ -979,6 +1122,7 @@ curl -sI http://127.0.0.1:{port} | head -1 || true self._log(f"[error] LLM chat failed: {exc}\n", "error") finally: await ssh.close() + self.root.after(0, lambda: self._set_chat_busy(False)) async def _async_run_script(self, script: str, ok_message: str = "", callback=None): ssh = self._make_ssh()