175 lines
5.1 KiB
Python
175 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Kleines TUI um chat.py.
|
|
|
|
- zeigt den Chatverlauf aus der Markdown-Datei
|
|
- mehrzeiliges Eingabefeld (Enter = neue Zeile, Ctrl+S = senden)
|
|
- die Antwort der LLM fliegt tokenweise rein
|
|
|
|
Quelle der Wahrheit bleibt die Datei (Default: chat.md). Jede gesendete
|
|
Nachricht und jede fertige Antwort wird sofort dort angehaengt.
|
|
|
|
python tui.py [pfad/zur/chat.md]
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from rich.markup import escape
|
|
from textual import work
|
|
from textual.app import App, ComposeResult
|
|
from textual.binding import Binding
|
|
from textual.containers import VerticalScroll
|
|
from textual.widgets import Footer, Header, Static, TextArea
|
|
|
|
import chat
|
|
|
|
ROLE_STYLE = {"system": "dim italic", "user": "bold cyan", "assistant": "bold green"}
|
|
|
|
|
|
class Message(Static):
|
|
"""Ein Chat-Eintrag; waehrend des Streamens waechst der Text."""
|
|
|
|
def __init__(self, role: str, text: str = "") -> None:
|
|
super().__init__(markup=True)
|
|
self.role = role
|
|
self._text = text
|
|
self.add_class(role)
|
|
self._rerender()
|
|
|
|
def _rerender(self) -> None:
|
|
style = ROLE_STYLE.get(self.role, "bold")
|
|
head = f"[{style}]{self.role}[/]"
|
|
body = escape(self._text) or "[dim]…[/]"
|
|
self.update(f"{head}\n{body}")
|
|
|
|
def append(self, chunk: str) -> None:
|
|
self._text += chunk
|
|
self._rerender()
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
return self._text
|
|
|
|
|
|
class ChatApp(App):
|
|
CSS = """
|
|
#log { padding: 1 2; }
|
|
Message { margin-bottom: 1; }
|
|
#input {
|
|
height: auto;
|
|
min-height: 3;
|
|
max-height: 15;
|
|
border: round $accent;
|
|
margin: 0 1 1 1;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("ctrl+s", "send", "Senden"),
|
|
Binding("ctrl+r", "reload", "Neu laden"),
|
|
Binding("ctrl+q", "quit", "Beenden"),
|
|
]
|
|
|
|
def __init__(self, path: str | Path) -> None:
|
|
super().__init__()
|
|
self.path = Path(path)
|
|
self.title = f"chat — {self.path.name}"
|
|
self._current: Message | None = None
|
|
self._busy = False
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header()
|
|
yield VerticalScroll(id="log")
|
|
yield TextArea(id="input")
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
self._load_history()
|
|
self.query_one("#input", TextArea).focus()
|
|
|
|
def _load_history(self) -> None:
|
|
"""Baut den sichtbaren Verlauf frisch aus der Datei auf."""
|
|
log = self.query_one("#log", VerticalScroll)
|
|
log.remove_children()
|
|
for msg in chat.load_messages(self.path):
|
|
log.mount(Message(msg["role"], msg["content"]))
|
|
log.scroll_end(animate=False)
|
|
|
|
def action_reload(self) -> None:
|
|
if self._busy:
|
|
self.notify("Antwort laeuft noch …", severity="warning")
|
|
return
|
|
self._load_history()
|
|
self.notify(f"{self.path.name} neu geladen.")
|
|
|
|
def action_send(self) -> None:
|
|
if self._busy:
|
|
self.notify("Antwort laeuft noch …", severity="warning")
|
|
return
|
|
area = self.query_one("#input", TextArea)
|
|
text = area.text.strip()
|
|
if not text:
|
|
return
|
|
area.text = ""
|
|
self._busy = True
|
|
|
|
chat.append_block(self.path, "user", text)
|
|
log = self.query_one("#log", VerticalScroll)
|
|
log.mount(Message("user", text))
|
|
self._current = Message("assistant")
|
|
log.mount(self._current)
|
|
log.scroll_end()
|
|
|
|
self._run_stream()
|
|
|
|
@work(thread=True)
|
|
def _run_stream(self) -> None:
|
|
messages = chat.load_messages(self.path)
|
|
gen = chat.stream_reply(messages)
|
|
finish: str | None = None
|
|
try:
|
|
while True:
|
|
try:
|
|
piece = next(gen)
|
|
except StopIteration as stop:
|
|
finish = stop.value
|
|
break
|
|
self.call_from_thread(self._on_token, piece)
|
|
except Exception as exc: # Netzwerk / Server / Parsing
|
|
self.call_from_thread(self._on_error, str(exc))
|
|
return
|
|
self.call_from_thread(self._on_done, finish)
|
|
|
|
def _on_token(self, piece: str) -> None:
|
|
assert self._current is not None
|
|
self._current.append(piece)
|
|
self.query_one("#log", VerticalScroll).scroll_end()
|
|
|
|
def _on_done(self, finish: str | None) -> None:
|
|
assert self._current is not None
|
|
chat.append_block(self.path, "assistant", self._current.text)
|
|
if finish == "length":
|
|
self.notify(
|
|
f"Antwort bei MAX_TOKENS={chat.MAX_TOKENS} abgeschnitten.",
|
|
severity="warning",
|
|
)
|
|
self._busy = False
|
|
self._current = None
|
|
self.query_one("#input", TextArea).focus()
|
|
|
|
def _on_error(self, msg: str) -> None:
|
|
if self._current is not None:
|
|
self._current.append(f"\n⚠ Fehler: {msg}")
|
|
self.notify(msg, title="Fehler", severity="error")
|
|
self._busy = False
|
|
self._current = None
|
|
|
|
|
|
def main() -> None:
|
|
path = sys.argv[1] if len(sys.argv) > 1 else "chat.md"
|
|
ChatApp(path).run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|