SHA256
add fisr unaable version
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
# ---> sjcode
|
||||
# Local daemon configuration and runtime data
|
||||
config.toml
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -1,3 +1,89 @@
|
||||
# sjcode
|
||||
|
||||
ein terminal coding agent, der die ollama API nutzt
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp config.toml.example config.toml # then edit port / model / limits
|
||||
python3 server/server.py # starts the daemon + admin CLI
|
||||
```
|
||||
|
||||
`config.toml` is machine-local and git-ignored; `config.toml.example` is the
|
||||
template. On first start the daemon creates the SQLite database (`sjcode.db`).
|
||||
|
||||
## Daemon (server.py)
|
||||
|
||||
On startup it prints its settings and drops into a minimal admin CLI
|
||||
(accounts are stored in SQLite):
|
||||
|
||||
| command | effect |
|
||||
|--------------------------|-------------------------------------------------|
|
||||
| `create <name> [role]` | create account (asks for password, default role `user`) |
|
||||
| `list` | show all accounts |
|
||||
| `delete <name>` | remove account |
|
||||
| `role <name> <role>` | change role |
|
||||
| `passwd <name>` | set a new password |
|
||||
| `usage [name]` | used minutes today vs. limit |
|
||||
| `reset [name]` | reset the daily usage counter |
|
||||
| `status` | show daemon settings |
|
||||
| `quit` | stop the daemon |
|
||||
|
||||
While the CLI runs, an HTTP API is served in the background:
|
||||
|
||||
- `POST /login` — `{"username", "password"}` → `{"token", ...}`
|
||||
- `POST /chat` — `{"token", "message", "model"?}` → `{"response", ...}`
|
||||
- `GET /models?token=...` — list ollama models
|
||||
|
||||
Every user row stores: `username`, `password_hash`, `last_logged_in`,
|
||||
`role`, `used_today` (minutes of AI usage; resets on a new day).
|
||||
|
||||
## Roles and limits
|
||||
|
||||
In `config.toml` each role gets a daily limit in minutes:
|
||||
|
||||
```toml
|
||||
[limits]
|
||||
user = 120 # default role for new accounts
|
||||
admin = -1 # negative = unlimited
|
||||
```
|
||||
|
||||
Login and every chat request are blocked once `used_today` reaches the limit.
|
||||
|
||||
## Client (client.py)
|
||||
|
||||
```bash
|
||||
./coder # or: python3 client.py
|
||||
python3 client.py 10.0.0.5:8420 # explicit daemon address
|
||||
python3 client.py --model qwen3-coder # skip the model picker
|
||||
python3 client.py --no-probe # skip the reachability check (use behind
|
||||
# HTTP proxies that answer on every port)
|
||||
```
|
||||
|
||||
On start the client asks which server to connect to: pick one of the saved
|
||||
ones, or add a new one (`n`). Every server you use is saved in
|
||||
`~/.config/sjcode/client.json` together with the username and the last
|
||||
model, so the next start is just Enter-Enter. Addresses without a port use
|
||||
8420; `host:port`, `http(s)://...` URLs and IPv6 literals are accepted.
|
||||
|
||||
After login and model choice you get the agent banner and a chat REPL.
|
||||
Commands:
|
||||
|
||||
| command | effect |
|
||||
|---------------|-----------------------------------------|
|
||||
| `/help` | list commands |
|
||||
| `/server` | show the connected server |
|
||||
| `/models` | list models on the server |
|
||||
| `/model <n>` | switch model (saved for this server) |
|
||||
| `/limit` | remaining minutes today (last known) |
|
||||
| `/clear` | forget the conversation history |
|
||||
| `/quit` | exit |
|
||||
|
||||
Anything else is sent to the model. The client keeps the last few turns as
|
||||
context (the daemon API itself is stateless) and renders the reply in the
|
||||
agent style (`✓` lines for actions the model reports).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+ (stdlib only; `tomllib` is included)
|
||||
- ollama listening on the address from `config.toml`
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sjcode client.
|
||||
|
||||
Terminal coding-agent front end for the sjcode daemon (server/server.py).
|
||||
|
||||
On start it asks which server to connect to. Servers you used before are
|
||||
remembered in ~/.config/sjcode/client.json together with username and the
|
||||
last model, so next time you just press Enter. New servers are saved too.
|
||||
|
||||
Flow:
|
||||
coder -> banner, pick saved server (or add one),
|
||||
login, pick model, chat REPL
|
||||
coder 10.0.0.5:8420 -> skip the server picker
|
||||
coder --model qwen3-coder -> skip the model picker
|
||||
|
||||
REPL commands:
|
||||
/help /server /model /models /limit /clear /quit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "sjcode"
|
||||
CONFIG_FILE = CONFIG_DIR / "client.json"
|
||||
|
||||
DEFAULT_PORT = 8420
|
||||
REQUEST_TIMEOUT = 300
|
||||
HISTORY_TURNS = 10 # prior turns sent with each message for context
|
||||
|
||||
HOST_RE = re.compile(
|
||||
r"^(?=.{1,253}\z)" # total length limit
|
||||
r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+" # domain labels +
|
||||
r"[a-z]{2,63}\z" # a TLD
|
||||
r"|^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z" # or single-label hostname
|
||||
r"|^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\z" # IPv4
|
||||
r"|^(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}\z", # IPv6 (2+ colons)
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Makes the model behave like a terminal agent: short lines, check marks.
|
||||
AGENT_PREAMBLE = (
|
||||
"You are sj, a coding agent working in the user's terminal.\n"
|
||||
"Answer concisely and practically.\n"
|
||||
"Describe what you do as short lines; put every action you performed "
|
||||
"or verified on its own line starting with '\u2713 ' (check mark).\n"
|
||||
"End your reply with the single line 'Done.'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# small terminal helpers (ANSI, no dependencies)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Style:
|
||||
def __init__(self, enabled: bool) -> None:
|
||||
self.dim = "\033[2m" if enabled else ""
|
||||
self.bold = "\033[1m" if enabled else ""
|
||||
self.green = "\033[32m" if enabled else ""
|
||||
self.yellow = "\033[33m" if enabled else ""
|
||||
self.red = "\033[31m" if enabled else ""
|
||||
self.cyan = "\033[36m" if enabled else ""
|
||||
self.reset = "\033[0m" if enabled else ""
|
||||
|
||||
|
||||
def style_enabled() -> bool:
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
|
||||
ST = Style(style_enabled())
|
||||
|
||||
|
||||
def ask(prompt: str) -> str:
|
||||
"""input() that exits cleanly on Ctrl+D instead of crashing."""
|
||||
try:
|
||||
return input(prompt)
|
||||
except EOFError:
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def banner(model: str, project: str, server: str) -> None:
|
||||
title = "sj Coding Agent"
|
||||
lines = [f"Model: {model}", f"Project: {project}"]
|
||||
width = max(len(title), *(len(l) for l in lines)) + 2
|
||||
print(f"\u256d{'\u2500' * (width + 2)}\u256e")
|
||||
print(f"\u2502 {title.center(width)} \u2502")
|
||||
for line in lines:
|
||||
print(f"\u2502 {line.ljust(width)} \u2502")
|
||||
print(f"\u2570{'\u2500' * (width + 2)}\u256f")
|
||||
print(f"{ST.dim}server: {server} (\u2018/help\u2019 for commands){ST.reset}")
|
||||
|
||||
|
||||
def shorten_home(path: Path) -> str:
|
||||
home = Path.home()
|
||||
try:
|
||||
return "~" / path.relative_to(home)
|
||||
except ValueError:
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# saved servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_state() -> dict:
|
||||
try:
|
||||
with open(CONFIG_FILE, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict) and isinstance(data.get("servers"), list):
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return {"servers": [], "last": ""}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||
json.dump(state, fh, indent=2)
|
||||
|
||||
|
||||
def server_label(entry: dict) -> str:
|
||||
return f"{entry['host']}:{entry['port']}"
|
||||
|
||||
|
||||
def find_server(state: dict, host: str, port: int) -> dict | None:
|
||||
for entry in state["servers"]:
|
||||
if entry["host"] == host and int(entry["port"]) == port:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def parse_address(text: str) -> tuple[str, int]:
|
||||
"""Accept 'domain', 'ip', 'host:port', IPv6 or a full http(s) URL."""
|
||||
text = text.strip()
|
||||
if "://" in text:
|
||||
parsed = urllib.parse.urlsplit(text)
|
||||
host, port = parsed.hostname or "", parsed.port or DEFAULT_PORT
|
||||
elif text.startswith("["): # [ipv6]:port
|
||||
parsed = urllib.parse.urlsplit("//" + text)
|
||||
host, port = parsed.hostname or "", parsed.port or DEFAULT_PORT
|
||||
elif text.count(":") > 1: # bare IPv6 literal (no port part)
|
||||
host, port = text, DEFAULT_PORT
|
||||
else:
|
||||
host, _, port_s = text.rpartition(":")
|
||||
if not host or not port_s.isdigit() or not (0 < int(port_s) < 65536):
|
||||
host, port = text, DEFAULT_PORT
|
||||
else:
|
||||
port = int(port_s)
|
||||
host = host.strip("[]").strip().lower()
|
||||
if not host or not HOST_RE.match(host):
|
||||
raise ValueError(f"not a valid host: {text!r}")
|
||||
return host, port
|
||||
|
||||
|
||||
def pick_server(state: dict) -> tuple[dict, bool]:
|
||||
"""Ask which server to use. Returns (entry, created_new)."""
|
||||
servers = state["servers"]
|
||||
print(f"\n{ST.bold}Connect to which server?{ST.reset}")
|
||||
for i, entry in enumerate(servers, 1):
|
||||
default = " (last)" if server_label(entry) == state.get("last") else ""
|
||||
user = f" as {entry['username']}" if entry.get("username") else ""
|
||||
print(f" {i}) {server_label(entry)}{user}{default}")
|
||||
print(f" n) add a new server\n")
|
||||
|
||||
hint = ""
|
||||
if servers:
|
||||
idx = next(
|
||||
(i for i, e in enumerate(servers, 1) if server_label(e) == state.get("last")),
|
||||
1,
|
||||
)
|
||||
hint = str(idx)
|
||||
|
||||
while True:
|
||||
choice = ask(f"server [1-{len(servers)}/n{', Enter=' + hint if hint else ''}]: ").strip().lower()
|
||||
if not choice and hint:
|
||||
entry = servers[int(hint) - 1]
|
||||
return entry, False
|
||||
if choice == "n":
|
||||
entry = add_server(state)
|
||||
return entry, True
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(servers):
|
||||
return servers[int(choice) - 1], False
|
||||
print(f"{ST.red}please enter 1-{len(servers)}, 'n', or Enter{ST.reset}")
|
||||
|
||||
|
||||
def add_server(state: dict) -> dict:
|
||||
"""Ask for a new server address, probe it and save it."""
|
||||
while True:
|
||||
raw = ask("server address (domain or ip:port): ").strip()
|
||||
try:
|
||||
host, port = parse_address(raw)
|
||||
except ValueError as exc:
|
||||
print(f"{ST.red}{exc} - use e.g. 192.168.1.10:8420 or code.my.lan{ST.reset}")
|
||||
continue
|
||||
entry = find_server(state, host, port)
|
||||
if entry is None:
|
||||
entry = {"host": host, "port": port, "username": "", "model": ""}
|
||||
state["servers"].append(entry)
|
||||
break
|
||||
return entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# daemon API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def http_json(method: str, base: str, path: str, payload: dict | None = None,
|
||||
timeout: int = REQUEST_TIMEOUT) -> tuple[int, dict]:
|
||||
url = base + path
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method=method,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
return exc.code, json.loads(exc.read().decode())
|
||||
except Exception:
|
||||
return exc.code, {"error": f"HTTP {exc.code}"}
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
|
||||
return 0, {"error": f"cannot reach server: {exc}"}
|
||||
|
||||
|
||||
def probe_server(entry: dict, probe: bool = True) -> str | None:
|
||||
"""Return an error string, or None if the daemon answers.
|
||||
|
||||
With probe=False, skip the check (e.g. HTTP proxies that intercept
|
||||
the port make a probe useless; login will fail with a clear error).
|
||||
"""
|
||||
if not probe:
|
||||
return None
|
||||
base = f"http://{entry['host']}:{entry['port']}"
|
||||
status, body = http_json("GET", base, "/status", timeout=5)
|
||||
if status != 200:
|
||||
return str(body.get("error", f"no answer from {server_label(entry)}"))
|
||||
return None
|
||||
|
||||
|
||||
def api_login(entry: dict, username: str, password: str) -> tuple[int, dict]:
|
||||
base = f"http://{entry['host']}:{entry['port']}"
|
||||
return http_json("POST", base, "/login",
|
||||
{"username": username, "password": password}, timeout=15)
|
||||
|
||||
|
||||
def api_models(entry: dict, token: str) -> list[str]:
|
||||
base = f"http://{entry['host']}:{entry['port']}"
|
||||
status, body = http_json(
|
||||
"GET", base, f"/models?token={urllib.parse.quote(token)}", timeout=15)
|
||||
return list(body.get("models", [])) if status == 200 else []
|
||||
|
||||
|
||||
def api_chat(entry: dict, token: str, message: str, model: str) -> tuple[int, dict]:
|
||||
base = f"http://{entry['host']}:{entry['port']}"
|
||||
return http_json("POST", base, "/chat",
|
||||
{"token": token, "message": message, "model": model})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# login / model selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def login(entry: dict) -> str | None:
|
||||
"""Ask credentials, return the session token (or None)."""
|
||||
default = entry.get("username") or ""
|
||||
while True:
|
||||
username = ask(f"username [{default}]: ").strip() or default
|
||||
if not username:
|
||||
print(f"{ST.red}username required{ST.reset}")
|
||||
continue
|
||||
if sys.stdin.isatty():
|
||||
try:
|
||||
password = getpass.getpass("password: ")
|
||||
except EOFError:
|
||||
print()
|
||||
sys.exit(1)
|
||||
else: # piped stdin (scripted runs): plain input
|
||||
password = ask("password: ")
|
||||
status, body = api_login(entry, username, password)
|
||||
if status == 200:
|
||||
entry["username"] = username
|
||||
left = body.get("remaining_minutes")
|
||||
time_left = "unlimited" if left is None else f"{left:g} min left today"
|
||||
print(f"{ST.green}logged in as {username}{ST.reset} "
|
||||
f"{ST.dim}({time_left}){ST.reset}\n")
|
||||
return str(body["token"])
|
||||
print(f"{ST.red}login failed: {body.get('error', 'unknown')}{ST.reset}")
|
||||
if status == 403: # daily limit reached - no point retrying
|
||||
return None
|
||||
|
||||
|
||||
def pick_model(entry: dict, models: list[str]) -> str:
|
||||
"""Ask which model to use; preselects the saved one."""
|
||||
if not models:
|
||||
print(f"{ST.yellow}server lists no models, using daemon default{ST.reset}")
|
||||
return ""
|
||||
if len(models) == 1:
|
||||
return models[0]
|
||||
|
||||
saved = entry.get("model") or ""
|
||||
print(f"{ST.bold}Models on {server_label(entry)}:{ST.reset}")
|
||||
for i, name in enumerate(models, 1):
|
||||
mark = f" {ST.dim}(last used){ST.reset}" if name == saved else ""
|
||||
print(f" {i}) {name}{mark}")
|
||||
print()
|
||||
|
||||
while True:
|
||||
choice = ask(f"model [1-{len(models)}, Enter="
|
||||
f"{models.index(saved) + 1 if saved in models else 1}]: ").strip()
|
||||
if not choice:
|
||||
return saved if saved in models else models[0]
|
||||
if choice in models:
|
||||
return choice
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(models):
|
||||
return models[int(choice) - 1]
|
||||
print(f"{ST.red}pick 1-{len(models)} or a model name{ST.reset}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the agent look
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_agent_reply(text: str) -> None:
|
||||
print(f"{ST.bold}Agent:{ST.reset}")
|
||||
for line in text.rstrip().splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("\u2713"):
|
||||
print(f" {ST.green}\u2713{ST.reset} {stripped.lstrip('\u2713').strip()}")
|
||||
elif stripped.lower() == "done.":
|
||||
print(f" {ST.bold}{stripped}{ST.reset}")
|
||||
else:
|
||||
print(f" {line.strip() if stripped else ''}")
|
||||
|
||||
|
||||
def build_prompt(history: list[tuple[str, str]], message: str) -> str:
|
||||
"""Plain-text transcript + new user message (server endpoint is stateless)."""
|
||||
parts = [AGENT_PREAMBLE]
|
||||
for user_text, answer in history[-HISTORY_TURNS:]:
|
||||
parts.append(f"User: {user_text}\nAssistant: {answer}")
|
||||
parts.append(f"User: {message}\nAssistant:")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat REPL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP = """
|
||||
commands:
|
||||
/help show this help
|
||||
/server show the connected server
|
||||
/models list models on the server
|
||||
/model <name> switch model (also saved for this server)
|
||||
/limit show remaining minutes today (last known)
|
||||
/clear forget the conversation history
|
||||
/quit exit (also Ctrl+D)
|
||||
anything else is sent to the model.
|
||||
"""
|
||||
|
||||
|
||||
def chat_loop(entry: dict, token: str, model: str, remaining: float | None) -> None:
|
||||
history: list[tuple[str, str]] = []
|
||||
while True:
|
||||
try:
|
||||
message = input(f"{ST.cyan}you>{ST.reset} ").strip()
|
||||
except EOFError:
|
||||
print()
|
||||
return
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
continue
|
||||
if not message:
|
||||
continue
|
||||
|
||||
if message.startswith("/"):
|
||||
cmd, _, arg = message.partition(" ")
|
||||
if cmd in ("/quit", "/exit", "/q"):
|
||||
return
|
||||
if cmd == "/help":
|
||||
print(HELP.strip())
|
||||
elif cmd == "/server":
|
||||
print(f"connected to {server_label(entry)} as {entry.get('username')}")
|
||||
elif cmd == "/models":
|
||||
models = api_models(entry, token)
|
||||
print("\n".join(models) or f"{ST.red}no models listed{ST.reset}")
|
||||
elif cmd == "/model":
|
||||
if arg.strip():
|
||||
model = arg.strip()
|
||||
entry["model"] = model
|
||||
print(f"model set to {model}")
|
||||
else:
|
||||
print(f"current model: {model or '(daemon default)'}")
|
||||
elif cmd == "/limit":
|
||||
left = "unlimited" if remaining is None else f"{remaining:g} min"
|
||||
print(f"time left today: {left}")
|
||||
elif cmd == "/clear":
|
||||
history.clear()
|
||||
print("history cleared")
|
||||
else:
|
||||
print(f"unknown command {cmd} - try /help")
|
||||
continue
|
||||
|
||||
try:
|
||||
status, body = api_chat(entry, token, build_prompt(history, message), model)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{ST.yellow}request cancelled{ST.reset}")
|
||||
continue
|
||||
|
||||
if status != 200:
|
||||
print(f"{ST.red}error: {body.get('error', 'unknown')}{ST.reset}")
|
||||
if status == 403:
|
||||
return # daily limit reached
|
||||
continue
|
||||
|
||||
answer = str(body.get("response", "")).strip() or "(empty reply)"
|
||||
history.append((message, answer))
|
||||
print()
|
||||
print_agent_reply(answer)
|
||||
remaining = body.get("remaining_minutes")
|
||||
if remaining is not None:
|
||||
print(f"{ST.dim} ({remaining:g} min left today){ST.reset}")
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
if os.name == "nt":
|
||||
os.system("") # enable ANSI escapes in classic Windows consoles
|
||||
|
||||
parser = argparse.ArgumentParser(description="sj coding agent client")
|
||||
parser.add_argument("server", nargs="?",
|
||||
help="server address (domain or ip:port), skips the picker")
|
||||
parser.add_argument("-m", "--model", help="model to use, skips the picker")
|
||||
parser.add_argument("--no-probe", action="store_true",
|
||||
help="skip the reachability check before login (use behind "
|
||||
"HTTP proxies that answer for every port)")
|
||||
args = parser.parse_args()
|
||||
|
||||
state = load_state()
|
||||
|
||||
# -- choose server ------------------------------------------------------
|
||||
if args.server:
|
||||
try:
|
||||
host, port = parse_address(args.server)
|
||||
except ValueError:
|
||||
sys.exit(f"error: cannot parse address '{args.server}'")
|
||||
entry = find_server(state, host, port)
|
||||
created = entry is None
|
||||
if entry is None:
|
||||
entry = {"host": host, "port": port, "username": "", "model": ""}
|
||||
state["servers"].append(entry)
|
||||
else:
|
||||
if not state["servers"]:
|
||||
print(f"{ST.bold}First start - let's add a server.{ST.reset}")
|
||||
entry = add_server(state)
|
||||
created = True
|
||||
else:
|
||||
entry, created = pick_server(state)
|
||||
|
||||
label = server_label(entry)
|
||||
if created:
|
||||
save_state(state) # persist immediately, before anything can go wrong
|
||||
|
||||
print(f"\nconnecting to {label} ...")
|
||||
if (err := probe_server(entry, probe=not args.no_probe)) is not None:
|
||||
print(f"{ST.red}error: {err}{ST.reset}")
|
||||
if created:
|
||||
print(f"{ST.dim}'{label}' was saved - start coder again once it is reachable{ST.reset}")
|
||||
else:
|
||||
print(f"{ST.dim}saved under '{label}' - try again later{ST.reset}")
|
||||
sys.exit(1)
|
||||
|
||||
# -- login ----------------------------------------------------------------
|
||||
token = login(entry)
|
||||
if token is None:
|
||||
save_state(state)
|
||||
sys.exit(1)
|
||||
|
||||
# -- choose model ---------------------------------------------------------
|
||||
models = api_models(entry, token)
|
||||
model = args.model or pick_model(entry, models)
|
||||
|
||||
if model:
|
||||
entry["model"] = model
|
||||
state["last"] = label
|
||||
save_state(state)
|
||||
|
||||
project = shorten_home(Path.cwd())
|
||||
banner(model or "(server default)", str(project), label)
|
||||
chat_loop(entry, token, model, None)
|
||||
print("bye")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nbye")
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# coder - launcher for the sjcode client.
|
||||
# Usage: coder [server] [-m model] (see client.py --help)
|
||||
exec python3 "$(dirname "$0")/client.py" "$@"
|
||||
@@ -0,0 +1,24 @@
|
||||
# sjcode daemon configuration
|
||||
# Copy this file to config.toml and adjust the values.
|
||||
|
||||
[server]
|
||||
# Port the daemon listens on (clients connect to this port).
|
||||
port = 8420
|
||||
# Bind address. Keep 127.0.0.1 unless you want LAN access.
|
||||
host = "127.0.0.1"
|
||||
|
||||
[ollama]
|
||||
# Where the daemon reaches the local ollama API.
|
||||
base_url = "http://127.0.0.1:11434"
|
||||
# Default model used when a client does not request a specific one.
|
||||
model = "llama3.2"
|
||||
|
||||
[database]
|
||||
# SQLite file for user accounts. Relative to the project root.
|
||||
path = "sjcode.db"
|
||||
|
||||
[limits]
|
||||
# How many minutes of AI usage each role gets per day.
|
||||
# "user" is the default role assigned to new accounts.
|
||||
user = 120
|
||||
admin = -1
|
||||
@@ -0,0 +1,590 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sjcode daemon.
|
||||
|
||||
Runs on the server, talks to the local ollama API (127.0.0.1) and serves
|
||||
sjcode clients on its own port (from config.toml).
|
||||
|
||||
On startup it drops into a minimal admin CLI; while that CLI runs, an
|
||||
HTTP server thread serves the client API:
|
||||
|
||||
POST /login {"username", "password"} -> {"token"}
|
||||
POST /chat {"token", "message", "model"?}
|
||||
-> {"response"} or {"error"} with 4xx
|
||||
GET /models?token=... -> {"models": [...]}
|
||||
|
||||
User accounts live in SQLite (sjcode.db) with:
|
||||
username, password_hash, last_logged_in, role, used_today (minutes).
|
||||
|
||||
Roles decide the daily usage limit (minutes) from config.toml [limits].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cmd
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
CONFIG_FILE = ROOT / "config.toml"
|
||||
DB_FILE = ROOT / "sjcode.db" # overwritten by config value once loaded
|
||||
|
||||
PEPPER = b"sjcode-v1"
|
||||
SESSION_TTL = 24 * 3600 # login token valid for one day
|
||||
|
||||
CONF: dict = {
|
||||
"server": {"host": "127.0.0.1", "port": 8420},
|
||||
"ollama": {"base_url": "http://127.0.0.1:11434", "model": "llama3.2"},
|
||||
"database": {"path": "sjcode.db"},
|
||||
"limits": {"user": 120, "admin": -1},
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> None:
|
||||
"""Read config.toml on top of the defaults."""
|
||||
global DB_FILE
|
||||
if not CONFIG_FILE.exists():
|
||||
sys.exit(
|
||||
f"error: {CONFIG_FILE.name} not found.\n"
|
||||
f"Copy {ROOT / 'config.toml.example'} to {CONFIG_FILE} and edit it."
|
||||
)
|
||||
try:
|
||||
with open(CONFIG_FILE, "rb") as fh:
|
||||
loaded = tomllib.load(fh)
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
sys.exit(f"error: invalid {CONFIG_FILE.name}: {exc}")
|
||||
|
||||
for section, values in loaded.items():
|
||||
if isinstance(values, dict) and isinstance(CONF.get(section), dict):
|
||||
CONF[section].update(values)
|
||||
DB_FILE = ROOT / CONF["database"]["path"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# database
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
used_today REAL NOT NULL DEFAULT 0,
|
||||
last_logged_in TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_FILE, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with db() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = f"{time.time_ns()}:{threading.get_ident()}".encode()
|
||||
digest = hashlib.scrypt(password.encode(), salt=PEPPER + salt, n=2**14, r=8, p=1)
|
||||
return f"{salt.decode()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
salt_s, hexdigest = stored.split("$", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
calc = hashlib.scrypt(
|
||||
password.encode(), salt=PEPPER + salt_s.encode(), n=2**14, r=8, p=1
|
||||
)
|
||||
return hmac.compare_digest(calc.hex(), hexdigest)
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
import secrets
|
||||
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def limit_for(role: str) -> float:
|
||||
limits = CONF["limits"]
|
||||
return float(limits.get(role, limits.get("user", 0)))
|
||||
|
||||
|
||||
def ask_password(prompt: str) -> str:
|
||||
"""Hidden input on a terminal, plain stdin otherwise (scriptable)."""
|
||||
if sys.stdin.isatty():
|
||||
import getpass
|
||||
|
||||
return getpass.getpass(prompt)
|
||||
print(prompt, end="", flush=True)
|
||||
return input("")
|
||||
|
||||
|
||||
def usage_reset_if_needed(conn: sqlite3.Connection, row: sqlite3.Row) -> None:
|
||||
"""used_today is a counter in minutes; reset it on a new calendar day (UTC)."""
|
||||
last_date = row["last_logged_in"][:10]
|
||||
if last_date and last_date != time.strftime("%Y-%m-%d", time.gmtime()):
|
||||
conn.execute(
|
||||
"UPDATE users SET used_today = 0 WHERE username = ?", (row["username"],)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_user(username: str):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
usage_reset_if_needed(conn, row)
|
||||
conn.commit()
|
||||
return row
|
||||
|
||||
|
||||
def remaining_minutes(username: str) -> float:
|
||||
row = get_user(username)
|
||||
if row is None:
|
||||
return 0.0
|
||||
limit = limit_for(row["role"])
|
||||
if limit < 0: # unlimited
|
||||
return float("inf")
|
||||
return max(0.0, limit - float(row["used_today"]))
|
||||
|
||||
|
||||
def add_used_minutes(username: str, minutes: float) -> None:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET used_today = used_today + ? WHERE username = ?",
|
||||
(minutes, username),
|
||||
)
|
||||
|
||||
|
||||
def set_last_login(username: str) -> None:
|
||||
stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET last_logged_in = ? WHERE username = ?", (stamp, username)
|
||||
)
|
||||
|
||||
|
||||
def create_session(username: str) -> str:
|
||||
token = new_token()
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (token, username, created_at) VALUES (?, ?, ?)",
|
||||
(token, username, int(time.time())),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def session_user(token: str):
|
||||
"""Return the username for a valid session token, else None."""
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT username, created_at FROM sessions WHERE token = ?", (token,)
|
||||
).fetchone()
|
||||
if row is None or int(time.time()) - row["created_at"] > SESSION_TTL:
|
||||
return None
|
||||
return row["username"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ollama
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def ollama_json(path: str, payload: dict | None = None, timeout: int = 300) -> tuple[int, dict]:
|
||||
url = CONF["ollama"]["base_url"].rstrip("/") + path
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode())
|
||||
except Exception:
|
||||
body = {"error": str(exc)}
|
||||
return exc.code, body
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
|
||||
return 502, {"error": f"ollama not reachable: {exc}"}
|
||||
|
||||
|
||||
def ollama_generate(prompt: str, model: str | None) -> tuple[int, str]:
|
||||
status, body = ollama_json(
|
||||
"/api/generate",
|
||||
{
|
||||
"model": model or CONF["ollama"]["model"],
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
if status != 200:
|
||||
return status, str(body.get("error", "ollama error"))
|
||||
return 200, str(body.get("response", ""))
|
||||
|
||||
|
||||
def list_models() -> tuple[int, list[str]]:
|
||||
status, body = ollama_json("/api/tags", timeout=10)
|
||||
if status != 200:
|
||||
return status, []
|
||||
names = [m.get("name", "") for m in body.get("models", [])]
|
||||
return 200, names
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP API
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ApiHandler(BaseHTTPRequestHandler):
|
||||
server_version = "sjcode-daemon/0.1"
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
def _json(self, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_body(self) -> dict:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
return json.loads(self.rfile.read(length).decode())
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None: # quiet by default
|
||||
return
|
||||
|
||||
# -- routes -----------------------------------------------------------
|
||||
def do_GET(self) -> None: # noqa: N802 (http.server API)
|
||||
if self.path.startswith("/models"):
|
||||
status, models = list_models()
|
||||
self._json(200 if status == 200 else 502, {"models": models})
|
||||
return
|
||||
if self.path == "/status":
|
||||
self._json(200, {"status": "ok", "daemon": "sjcode"})
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 (http.server API)
|
||||
data = self._read_body()
|
||||
|
||||
if self.path == "/login":
|
||||
self._handle_login(data)
|
||||
return
|
||||
if self.path == "/chat":
|
||||
self._handle_chat(data)
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
# -- handlers ---------------------------------------------------------
|
||||
def _handle_login(self, data: dict) -> None:
|
||||
username = str(data.get("username", "")).strip()
|
||||
password = str(data.get("password", ""))
|
||||
if not username or not password:
|
||||
self._json(400, {"error": "username and password required"})
|
||||
return
|
||||
|
||||
row = get_user(username)
|
||||
if row is None or not verify_password(password, row["password_hash"]):
|
||||
self._json(401, {"error": "invalid credentials"})
|
||||
return
|
||||
|
||||
limit = limit_for(row["role"])
|
||||
left = remaining_minutes(username)
|
||||
if left <= 0:
|
||||
self._json(
|
||||
403,
|
||||
{"error": "daily limit reached", "limit_minutes": limit},
|
||||
)
|
||||
return
|
||||
|
||||
set_last_login(username)
|
||||
token = create_session(username)
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"token": token,
|
||||
"role": row["role"],
|
||||
"remaining_minutes": None if left == float("inf") else round(left, 1),
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_chat(self, data: dict) -> None:
|
||||
token = str(data.get("token", ""))
|
||||
username = session_user(token)
|
||||
if username is None:
|
||||
self._json(401, {"error": "not logged in"})
|
||||
return
|
||||
|
||||
message = str(data.get("message", "")).strip()
|
||||
if not message:
|
||||
self._json(400, {"error": "empty message"})
|
||||
return
|
||||
|
||||
left = remaining_minutes(username)
|
||||
if left <= 0:
|
||||
self._json(403, {"error": "daily limit reached"})
|
||||
return
|
||||
|
||||
model = str(data.get("model") or "") or CONF["ollama"]["model"]
|
||||
|
||||
started = time.monotonic()
|
||||
status, answer = ollama_generate(message, model)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
if status != 200:
|
||||
self._json(status, {"error": answer})
|
||||
return
|
||||
|
||||
used_min = elapsed / 60.0
|
||||
add_used_minutes(username, used_min)
|
||||
left_now = remaining_minutes(username)
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"response": answer,
|
||||
"model": model,
|
||||
"used_minutes": round(used_min, 4),
|
||||
"remaining_minutes": None if left_now == float("inf") else round(left_now, 2),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# admin CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class DaemonCLI(cmd.Cmd):
|
||||
"""Minimal admin shell shown after daemon startup."""
|
||||
|
||||
intro = (
|
||||
"sjcode daemon CLI. Type 'help' for commands.\n"
|
||||
"Accounts are stored in SQLite; the HTTP API runs in the background."
|
||||
)
|
||||
prompt = "sjcode-admin> "
|
||||
|
||||
def __init__(self, server_ref: dict) -> None:
|
||||
super().__init__()
|
||||
self.server_ref = server_ref
|
||||
|
||||
# -- account management ----------------------------------------------
|
||||
def do_create(self, arg: str) -> None:
|
||||
"""create <username> [role] - create an account (asks for password)"""
|
||||
parts = arg.split()
|
||||
if not parts:
|
||||
print("usage: create <username> [role]")
|
||||
return
|
||||
username = parts[0]
|
||||
role = parts[1] if len(parts) > 1 else "user"
|
||||
if get_user(username) is not None:
|
||||
print(f"user '{username}' already exists")
|
||||
return
|
||||
pw1 = ask_password("password: ")
|
||||
pw2 = ask_password("password (again): ")
|
||||
if len(pw1) < 4:
|
||||
print("password too short (min 4 chars)")
|
||||
return
|
||||
if pw1 != pw2:
|
||||
print("passwords do not match")
|
||||
return
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
username,
|
||||
hash_password(pw1),
|
||||
role,
|
||||
time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()),
|
||||
),
|
||||
)
|
||||
print(f"created '{username}' with role '{role}' "
|
||||
f"(limit: {limit_for(role)} min/day)")
|
||||
|
||||
def do_list(self, arg: str) -> None:
|
||||
"""list - show all accounts"""
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT username, role, used_today, last_logged_in "
|
||||
"FROM users ORDER BY username"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
print("no accounts yet. use 'create <username>'")
|
||||
return
|
||||
print(f"{'username':<20} {'role':<10} {'used today':>10} last login")
|
||||
for r in rows:
|
||||
print(
|
||||
f"{r['username']:<20} {r['role']:<10} "
|
||||
f"{r['used_today']:>10.1f} {r['last_logged_in'] or '-'}"
|
||||
)
|
||||
|
||||
def do_delete(self, arg: str) -> None:
|
||||
"""delete <username> - remove an account"""
|
||||
username = arg.strip().split()[0] if arg.strip() else ""
|
||||
if not username:
|
||||
print("usage: delete <username>")
|
||||
return
|
||||
with db() as conn:
|
||||
cur = conn.execute("DELETE FROM users WHERE username = ?", (username,))
|
||||
if cur.rowcount:
|
||||
print(f"deleted '{username}'")
|
||||
else:
|
||||
print(f"no user '{username}'")
|
||||
|
||||
def do_role(self, arg: str) -> None:
|
||||
"""role <username> <role> - change a user's role"""
|
||||
parts = arg.split()
|
||||
if len(parts) != 2:
|
||||
print("usage: role <username> <role>")
|
||||
return
|
||||
username, role = parts
|
||||
with db() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE users SET role = ? WHERE username = ?", (role, username)
|
||||
)
|
||||
if cur.rowcount:
|
||||
print(f"'{username}' now has role '{role}' "
|
||||
f"(limit: {limit_for(role)} min/day)")
|
||||
else:
|
||||
print(f"no user '{username}'")
|
||||
|
||||
def do_passwd(self, arg: str) -> None:
|
||||
"""passwd <username> - set a new password for a user"""
|
||||
username = arg.strip().split()[0] if arg.strip() else ""
|
||||
if not username:
|
||||
print("usage: passwd <username>")
|
||||
return
|
||||
row = get_user(username)
|
||||
if row is None:
|
||||
print(f"no user '{username}'")
|
||||
return
|
||||
pw = ask_password("new password: ")
|
||||
if len(pw) < 4:
|
||||
print("password too short (min 4 chars)")
|
||||
return
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE username = ?",
|
||||
(hash_password(pw), username),
|
||||
)
|
||||
print(f"password updated for '{username}'")
|
||||
|
||||
def do_usage(self, arg: str) -> None:
|
||||
"""usage [username] - show used minutes / limit for today"""
|
||||
with db() as conn:
|
||||
if arg.strip():
|
||||
rows = conn.execute(
|
||||
"SELECT username, role, used_today FROM users "
|
||||
"WHERE username = ?",
|
||||
(arg.strip(),),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT username, role, used_today FROM users "
|
||||
"ORDER BY username"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
print("no such user" if arg.strip() else "no accounts yet")
|
||||
return
|
||||
for r in rows:
|
||||
limit = limit_for(r["role"])
|
||||
lim = "unlimited" if limit < 0 else f"{limit:g} min"
|
||||
print(f"{r['username']:<20} {r['used_today']:>7.1f} min / {lim}")
|
||||
|
||||
def do_reset(self, arg: str) -> None:
|
||||
"""reset [username] - reset the used_today counter"""
|
||||
with db() as conn:
|
||||
if arg.strip():
|
||||
conn.execute(
|
||||
"UPDATE users SET used_today = 0 WHERE username = ?",
|
||||
(arg.strip(),),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE users SET used_today = 0")
|
||||
print("usage counter reset")
|
||||
|
||||
# -- server control ---------------------------------------------------
|
||||
def do_status(self, arg: str) -> None:
|
||||
"""status - show daemon settings"""
|
||||
host, port = CONF["server"]["host"], CONF["server"]["port"]
|
||||
print(f"listening on http://{host}:{port}")
|
||||
print(f"ollama at {CONF['ollama']['base_url']} (model: {CONF['ollama']['model']})")
|
||||
print(f"database at {DB_FILE}")
|
||||
|
||||
def do_quit(self, arg: str) -> bool:
|
||||
"""quit - stop the daemon"""
|
||||
print("shutting down...")
|
||||
self.server_ref["stop"] = True
|
||||
srv: ThreadingHTTPServer = self.server_ref["httpd"]
|
||||
srv.shutdown()
|
||||
return True
|
||||
|
||||
def do_exit(self, arg: str) -> bool:
|
||||
"""exit - stop the daemon (same as quit)"""
|
||||
return self.do_quit(arg)
|
||||
|
||||
do_EOF = do_quit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# main
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
load_config()
|
||||
init_db()
|
||||
|
||||
host = CONF["server"]["host"]
|
||||
port = int(CONF["server"]["port"])
|
||||
|
||||
try:
|
||||
httpd = ThreadingHTTPServer((host, port), ApiHandler)
|
||||
except OSError as exc:
|
||||
sys.exit(f"error: cannot listen on {host}:{port}: {exc}")
|
||||
|
||||
ref = {"httpd": httpd, "stop": False}
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
print(f"sjcode daemon listening on http://{host}:{port}")
|
||||
print(f"ollama: {CONF['ollama']['base_url']} model: {CONF['ollama']['model']}")
|
||||
print(f"database: {DB_FILE}")
|
||||
|
||||
try:
|
||||
DaemonCLI(ref).cmdloop()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
print("bye")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user