feat: add local chat provider and CLI

This commit is contained in:
2026-07-30 17:00:37 +02:00
parent 10d7cd7662
commit ec9b2f94ad
7 changed files with 704 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Allow ``python -m javis`` to invoke the CLI."""
from javis.interface.cli import main
raise SystemExit(main())
+96
View File
@@ -0,0 +1,96 @@
"""Central runtime configuration for the local Javis chat."""
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
class ConfigurationError(ValueError):
"""Raised when runtime configuration is unsafe or invalid."""
def default_data_dir(
environ: Mapping[str, str] | None = None,
*,
platform_name: str | None = None,
home: Path | None = None,
) -> Path:
"""Return a platform-appropriate user data directory."""
values = os.environ if environ is None else environ
platform = os.name if platform_name is None else platform_name
user_home = Path.home() if home is None else home
if platform == "nt":
base = Path(values.get("LOCALAPPDATA", user_home / "AppData" / "Local"))
return base / "Javis"
xdg_data_home = values.get("XDG_DATA_HOME")
base = Path(xdg_data_home) if xdg_data_home else user_home / ".local" / "share"
return base / "javis"
@dataclass(frozen=True, slots=True)
class Settings:
"""Validated settings shared by CLI, provider, and storage."""
data_dir: Path
provider: str = "ollama"
model: str = "qwen3:8b"
ollama_base_url: str = "http://127.0.0.1:11434"
timeout_seconds: float = 180.0
@property
def database_path(self) -> Path:
return self.data_dir / "sessions.sqlite3"
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> Settings:
values = os.environ if environ is None else environ
configured_dir = values.get("JAVIS_DATA_DIR")
data_dir = Path(configured_dir).expanduser() if configured_dir else default_data_dir(values)
if not data_dir.is_absolute():
raise ConfigurationError("JAVIS_DATA_DIR muss ein absoluter Pfad sein.")
provider = values.get("JAVIS_PROVIDER", "ollama").strip()
model = values.get("JAVIS_MODEL", "qwen3:8b").strip()
base_url = values.get("JAVIS_OLLAMA_URL", "http://127.0.0.1:11434").strip()
timeout_raw = values.get("JAVIS_MODEL_TIMEOUT", "180")
try:
timeout = float(timeout_raw)
except ValueError as exc:
raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss eine Zahl sein.") from exc
settings = cls(
data_dir=data_dir.resolve(),
provider=provider,
model=model,
ollama_base_url=base_url,
timeout_seconds=timeout,
)
settings.validate()
return settings
def validate(self) -> None:
if self.provider != "ollama":
raise ConfigurationError(
"Unbekannter Provider. In dieser Phase wird nur 'ollama' unterstützt."
)
if not self.model:
raise ConfigurationError("JAVIS_MODEL darf nicht leer sein.")
if self.timeout_seconds <= 0:
raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss größer als 0 sein.")
parsed = urlparse(self.ollama_base_url)
if parsed.scheme != "http" or parsed.hostname not in {
"127.0.0.1",
"::1",
"localhost",
}:
raise ConfigurationError("JAVIS_OLLAMA_URL muss eine lokale HTTP-Adresse sein.")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ConfigurationError("JAVIS_OLLAMA_URL enthält unzulässige Bestandteile.")
+57
View File
@@ -0,0 +1,57 @@
"""Chat orchestration without interface or provider-specific details."""
from __future__ import annotations
from dataclasses import dataclass
from javis.memory.sqlite_store import ChatSession, SQLiteSessionStore
from javis.providers.base import ChatMessage, LocalModelProvider
class SessionProviderMismatchError(RuntimeError):
"""The active provider cannot safely continue the stored session."""
@dataclass(frozen=True, slots=True)
class LoadedSession:
session: ChatSession
messages: list[ChatMessage]
class ChatService:
def __init__(
self,
store: SQLiteSessionStore,
provider: LocalModelProvider,
) -> None:
self.store = store
self.provider = provider
def new_session(self) -> ChatSession:
return self.store.create_session(self.provider.name, self.provider.model)
def list_sessions(self) -> list[ChatSession]:
return self.store.list_sessions()
def load_session(self, session_id: str) -> LoadedSession:
session = self.store.get_session(session_id)
if session.provider != self.provider.name or session.model != self.provider.model:
raise SessionProviderMismatchError(
"Die Sitzung verwendet "
f"{session.provider}/{session.model}, aktiv ist "
f"{self.provider.name}/{self.provider.model}."
)
return LoadedSession(session, self.store.get_messages(session_id))
def send(self, session_id: str, text: str) -> str:
normalized = text.strip()
if not normalized:
raise ValueError("Eine leere Nachricht wird nicht gesendet.")
loaded = self.load_session(session_id)
messages = [*loaded.messages, ChatMessage("user", normalized)]
response = self.provider.chat(messages)
self.store.append_exchange(session_id, normalized, response)
return response
def clear_session(self, session_id: str) -> None:
self.store.clear_messages(session_id)
+182
View File
@@ -0,0 +1,182 @@
"""Command-line interface for the first local Javis text chat."""
from __future__ import annotations
import argparse
import sys
from collections.abc import Callable, Sequence
from javis.config.settings import ConfigurationError, Settings
from javis.core.chat_service import ChatService, SessionProviderMismatchError
from javis.memory.sqlite_store import (
ChatSession,
SessionNotFoundError,
SessionStoreError,
SQLiteSessionStore,
)
from javis.providers.base import ProviderError
from javis.providers.ollama import OllamaProvider
InputFunction = Callable[[str], str]
OutputFunction = Callable[[str], None]
HELP_TEXT = """Befehle:
/new neue Sitzung beginnen
/sessions gespeicherte Sitzungen auflisten
/load <ID> frühere Sitzung laden und fortsetzen
/clear Nachrichten der aktiven Sitzung leeren
/help diese Hilfe anzeigen
/exit Chat ordentlich beenden"""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="javis", description="Lokaler Javis-Textchat")
subparsers = parser.add_subparsers(dest="command", required=True)
chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten")
chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden")
return parser
def _show_history(service: ChatService, session_id: str, output: OutputFunction) -> None:
loaded = service.load_session(session_id)
if not loaded.messages:
output("(Sitzung enthält noch keine Nachrichten.)")
return
for message in loaded.messages:
label = "Du" if message.role == "user" else "Javis"
output(f"{label}: {message.content}")
def _show_sessions(sessions: list[ChatSession], output: OutputFunction) -> None:
if not sessions:
output("Keine gespeicherten Sitzungen vorhanden.")
return
for session in sessions:
output(
f"{session.id} | {session.updated_at} | "
f"{session.provider}/{session.model} | {session.message_count} Nachrichten"
)
def run_chat(
service: ChatService,
*,
session_id: str | None = None,
input_fn: InputFunction = input,
output: OutputFunction = print,
) -> int:
try:
if session_id:
active_id = service.load_session(session_id).session.id
output(f"Sitzung geladen: {active_id}")
_show_history(service, active_id, output)
else:
active_id = service.new_session().id
output(f"Neue Sitzung: {active_id}")
except (
SessionNotFoundError,
SessionProviderMismatchError,
SessionStoreError,
) as exc:
output(f"Fehler: {exc}")
return 2
output("Lokaler Javis-Chat. /help zeigt die Befehle.")
while True:
try:
entered = input_fn("Du> ").strip()
except (EOFError, KeyboardInterrupt):
output("\nChat beendet.")
return 0
if not entered:
continue
if entered == "/exit":
output("Chat beendet.")
return 0
if entered == "/help":
output(HELP_TEXT)
continue
if entered == "/new":
try:
active_id = service.new_session().id
output(f"Neue Sitzung: {active_id}")
except SessionStoreError as exc:
output(f"Fehler: {exc}")
continue
if entered == "/sessions":
try:
_show_sessions(service.list_sessions(), output)
except SessionStoreError as exc:
output(f"Fehler: {exc}")
continue
if entered == "/clear":
try:
service.clear_session(active_id)
output("Aktive Sitzung wurde geleert.")
except (SessionNotFoundError, SessionStoreError) as exc:
output(f"Fehler: {exc}")
continue
if entered.startswith("/load"):
parts = entered.split(maxsplit=1)
if len(parts) != 2 or not parts[1].strip():
output("Verwendung: /load <Sitzungs-ID>")
continue
try:
loaded = service.load_session(parts[1].strip())
active_id = loaded.session.id
output(f"Sitzung geladen: {active_id}")
_show_history(service, active_id, output)
except (
SessionNotFoundError,
SessionProviderMismatchError,
SessionStoreError,
) as exc:
output(f"Fehler: {exc}")
continue
if entered.startswith("/"):
output("Unbekannter Befehl. /help zeigt die verfügbaren Befehle.")
continue
try:
response = service.send(active_id, entered)
output(f"Javis: {response}")
except (ProviderError, SessionStoreError, SessionProviderMismatchError) as exc:
output(f"Fehler: {exc}")
except ValueError as exc:
output(f"Fehler: {exc}")
def main(
argv: Sequence[str] | None = None,
*,
input_fn: InputFunction = input,
output: OutputFunction = print,
) -> int:
args = build_parser().parse_args(argv)
if args.command != "chat":
return 2
try:
settings = Settings.from_env()
store = SQLiteSessionStore(settings.database_path)
provider = OllamaProvider(
settings.model,
settings.ollama_base_url,
settings.timeout_seconds,
)
except (ConfigurationError, SessionStoreError) as exc:
output(f"Konfigurationsfehler: {exc}")
return 2
return run_chat(
ChatService(store, provider),
session_id=args.session,
input_fn=input_fn,
output=output,
)
if __name__ == "__main__":
sys.exit(main())
+236
View File
@@ -0,0 +1,236 @@
"""SQLite persistence for local chat sessions."""
from __future__ import annotations
import sqlite3
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from javis.providers.base import ChatMessage
class SessionStoreError(RuntimeError):
"""Base class for expected persistence failures."""
class SessionNotFoundError(SessionStoreError):
"""A requested session ID does not exist."""
@dataclass(frozen=True, slots=True)
class ChatSession:
id: str
created_at: str
updated_at: str
provider: str
model: str
message_count: int = 0
def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
class SQLiteSessionStore:
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
try:
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._initialize()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError(
f"Die Sitzungsdatenbank kann nicht geöffnet werden: {database_path}"
) from exc
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=10)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
@contextmanager
def _connection(self) -> Iterator[sqlite3.Connection]:
connection = self._connect()
try:
with connection:
yield connection
finally:
connection.close()
def _initialize(self) -> None:
with self._connection() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
position INTEGER NOT NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
UNIQUE (session_id, position)
);
CREATE INDEX IF NOT EXISTS idx_messages_session_position
ON messages(session_id, position);
"""
)
def create_session(self, provider: str, model: str) -> ChatSession:
session_id = str(uuid.uuid4())
now = _utc_now()
try:
with self._connection() as connection:
connection.execute(
"""
INSERT INTO sessions (id, created_at, updated_at, provider, model)
VALUES (?, ?, ?, ?, ?)
""",
(session_id, now, now, provider, model),
)
except sqlite3.Error as exc:
raise SessionStoreError("Die Sitzung konnte nicht gespeichert werden.") from exc
return ChatSession(session_id, now, now, provider, model)
def get_session(self, session_id: str) -> ChatSession:
try:
with self._connection() as connection:
row = connection.execute(
"""
SELECT s.*, COUNT(m.id) AS message_count
FROM sessions AS s
LEFT JOIN messages AS m ON m.session_id = s.id
WHERE s.id = ?
GROUP BY s.id
""",
(session_id,),
).fetchone()
except sqlite3.Error as exc:
raise SessionStoreError("Die Sitzung konnte nicht geladen werden.") from exc
if row is None:
raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.")
return self._session_from_row(row)
def list_sessions(self) -> list[ChatSession]:
try:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT s.*, COUNT(m.id) AS message_count
FROM sessions AS s
LEFT JOIN messages AS m ON m.session_id = s.id
GROUP BY s.id
ORDER BY s.updated_at DESC, s.created_at DESC
"""
).fetchall()
except sqlite3.Error as exc:
raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc
return [self._session_from_row(row) for row in rows]
def get_messages(self, session_id: str) -> list[ChatMessage]:
self.get_session(session_id)
try:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT role, content
FROM messages
WHERE session_id = ?
ORDER BY position ASC
""",
(session_id,),
).fetchall()
except sqlite3.Error as exc:
raise SessionStoreError("Die Nachrichten konnten nicht geladen werden.") from exc
return [ChatMessage(row["role"], row["content"]) for row in rows]
def append_exchange(
self,
session_id: str,
user_content: str,
assistant_content: str,
) -> None:
now = _utc_now()
try:
with self._connection() as connection:
exists = connection.execute(
"SELECT 1 FROM sessions WHERE id = ?", (session_id,)
).fetchone()
if exists is None:
raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.")
row = connection.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM messages WHERE session_id = ?",
(session_id,),
).fetchone()
next_position = int(row[0])
connection.executemany(
"""
INSERT INTO messages
(session_id, position, role, content, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
(session_id, next_position, "user", user_content, now),
(
session_id,
next_position + 1,
"assistant",
assistant_content,
now,
),
),
)
connection.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?",
(now, session_id),
)
except SessionNotFoundError:
raise
except sqlite3.Error as exc:
raise SessionStoreError("Die Nachrichten konnten nicht gespeichert werden.") from exc
def clear_messages(self, session_id: str) -> None:
now = _utc_now()
try:
with self._connection() as connection:
cursor = connection.execute(
"DELETE FROM messages WHERE session_id = ?", (session_id,)
)
exists = connection.execute(
"SELECT 1 FROM sessions WHERE id = ?", (session_id,)
).fetchone()
if exists is None:
raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.")
if cursor.rowcount:
connection.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?",
(now, session_id),
)
except SessionNotFoundError:
raise
except sqlite3.Error as exc:
raise SessionStoreError("Die Sitzung konnte nicht geleert werden.") from exc
@staticmethod
def _session_from_row(row: sqlite3.Row) -> ChatSession:
return ChatSession(
id=row["id"],
created_at=row["created_at"],
updated_at=row["updated_at"],
provider=row["provider"],
model=row["model"],
message_count=row["message_count"],
)
+46
View File
@@ -0,0 +1,46 @@
"""Small provider contract used by the chat core."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass(frozen=True, slots=True)
class ChatMessage:
role: str
content: str
class ProviderError(RuntimeError):
"""Base class for expected local provider failures."""
class ProviderUnavailableError(ProviderError):
"""The local model runtime cannot be reached."""
class ModelNotInstalledError(ProviderError):
"""The configured local model is not available."""
class ProviderTimeoutError(ProviderError):
"""The local model did not respond in time."""
class ResponseAbortedError(ProviderError):
"""The model response was interrupted before completion."""
class InvalidProviderResponseError(ProviderError):
"""The local runtime returned an unusable response."""
@runtime_checkable
class LocalModelProvider(Protocol):
name: str
model: str
def chat(self, messages: list[ChatMessage]) -> str:
"""Return one complete assistant response."""
...
+82
View File
@@ -0,0 +1,82 @@
"""Ollama implementation of the local model provider contract."""
from __future__ import annotations
import json
import socket
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from javis.providers.base import (
ChatMessage,
InvalidProviderResponseError,
ModelNotInstalledError,
ProviderTimeoutError,
ProviderUnavailableError,
ResponseAbortedError,
)
class OllamaProvider:
name = "ollama"
def __init__(self, model: str, base_url: str, timeout_seconds: float) -> None:
self.model = model
self._endpoint = f"{base_url.rstrip('/')}/api/chat"
self._timeout_seconds = timeout_seconds
def chat(self, messages: list[ChatMessage]) -> str:
payload = {
"model": self.model,
"messages": [
{"role": message.role, "content": message.content} for message in messages
],
"stream": False,
"think": False,
}
request = Request(
self._endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=self._timeout_seconds) as response:
raw_response = response.read()
except HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace")
if exc.code == 404 or "not found" in details.lower():
raise ModelNotInstalledError(
f"Das lokale Modell '{self.model}' ist nicht installiert."
) from exc
raise ProviderUnavailableError(f"Ollama meldet HTTP-Fehler {exc.code}.") from exc
except TimeoutError as exc:
raise ProviderTimeoutError(
"Die Modellantwort hat das Zeitlimit überschritten."
) from exc
except URLError as exc:
if isinstance(exc.reason, (TimeoutError, socket.timeout)):
raise ProviderTimeoutError(
"Die Modellantwort hat das Zeitlimit überschritten."
) from exc
raise ProviderUnavailableError(
"Ollama ist unter der konfigurierten lokalen Adresse nicht erreichbar."
) from exc
try:
result = json.loads(raw_response)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise InvalidProviderResponseError(
"Ollama hat keine gültige JSON-Antwort geliefert."
) from exc
if result.get("done") is False:
raise ResponseAbortedError("Die Modellantwort wurde vorzeitig abgebrochen.")
content = result.get("message", {}).get("content")
if not isinstance(content, str) or not content.strip():
raise InvalidProviderResponseError(
"Ollama hat keine verwendbare Textantwort geliefert."
)
return content.strip()