Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
This commit is contained in:
238
src/jarvis/llm.py
Normal file
238
src/jarvis/llm.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Direct LLM interaction utilities without extra features like temporal context."""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Any, Dict, List, Generator, Callable
|
||||
import requests
|
||||
import json
|
||||
|
||||
from .debug import debug_log
|
||||
|
||||
|
||||
class ToolsNotSupportedError(Exception):
|
||||
"""Raised when the model returns HTTP 400 because native tool calling is not supported."""
|
||||
pass
|
||||
|
||||
|
||||
def call_llm_direct(base_url: str, chat_model: str, system_prompt: str, user_content: str, timeout_sec: float = 10.0, thinking: bool = False, num_ctx: int = 4096, temperature: Optional[float] = None) -> Optional[str]:
|
||||
"""Direct LLM call without temporal context, location, or other ask_coach features.
|
||||
|
||||
``num_ctx`` controls Ollama's context window for this call. Default 4096 is
|
||||
fine for small classification-shaped passes; callers that assemble richer
|
||||
prompts (planner with dialogue + memory + tool catalogue) should pass a
|
||||
larger value to avoid silent truncation.
|
||||
|
||||
``temperature`` is forwarded to Ollama when set. Pass ``0.0`` for
|
||||
classification / extraction calls where determinism beats creativity —
|
||||
Ollama defaults to ~0.8 otherwise, which can flake small models on
|
||||
rule-following tasks (e.g. the knowledge extractor's banned-form list).
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content}
|
||||
]
|
||||
|
||||
options: Dict[str, Any] = {"num_ctx": num_ctx}
|
||||
if temperature is not None:
|
||||
options["temperature"] = temperature
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": chat_model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": options,
|
||||
"think": thinking,
|
||||
}
|
||||
|
||||
try:
|
||||
with requests.post(f"{base_url.rstrip('/')}/api/chat", json=payload, timeout=timeout_sec) as resp:
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if isinstance(data, dict):
|
||||
content = extract_text_from_response(data)
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content
|
||||
debug_log(f"call_llm_direct: empty content from response keys={list(data.keys())}", "llm")
|
||||
except requests.exceptions.Timeout:
|
||||
debug_log(f"call_llm_direct: timeout after {timeout_sec}s", "llm")
|
||||
return None
|
||||
except Exception as e:
|
||||
debug_log(f"call_llm_direct: request failed — {e}", "llm")
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def call_llm_streaming(
|
||||
base_url: str,
|
||||
chat_model: str,
|
||||
system_prompt: str,
|
||||
user_content: str,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
timeout_sec: float = 30.0,
|
||||
thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Streaming LLM call that invokes on_token callback for each token received.
|
||||
|
||||
Args:
|
||||
base_url: Ollama base URL
|
||||
chat_model: Model name
|
||||
system_prompt: System prompt
|
||||
user_content: User message
|
||||
on_token: Callback invoked with each token as it arrives
|
||||
timeout_sec: Request timeout
|
||||
thinking: Enable thinking/reasoning mode
|
||||
|
||||
Returns:
|
||||
Complete response text, or None on error
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content}
|
||||
]
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": chat_model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"options": {"num_ctx": 4096},
|
||||
"think": thinking,
|
||||
}
|
||||
|
||||
# Use ``with`` so the streaming response (and the underlying TCP
|
||||
# connection) is released even if iter_lines exits early via an
|
||||
# exception or the caller stops consuming. Without this an aborted
|
||||
# stream pinned the connection until GC, which could happen many
|
||||
# turns later under sustained reply load.
|
||||
try:
|
||||
with requests.post(
|
||||
f"{base_url.rstrip('/')}/api/chat",
|
||||
json=payload,
|
||||
timeout=timeout_sec,
|
||||
stream=True,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
full_response = []
|
||||
for line in resp.iter_lines():
|
||||
if line:
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if "message" in data and isinstance(data["message"], dict):
|
||||
content = data["message"].get("content", "")
|
||||
if content:
|
||||
full_response.append(content)
|
||||
if on_token:
|
||||
on_token(content)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
result = "".join(full_response)
|
||||
return result if result.strip() else None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract_text_from_response(data: Dict[str, Any]) -> Optional[str]:
|
||||
"""Extract text from LLM response - supports multiple response formats."""
|
||||
# Preferred: Ollama chat non-stream format
|
||||
if "message" in data and isinstance(data["message"], dict):
|
||||
content = data["message"].get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
# Fallback: OpenAI-style format
|
||||
if "choices" in data and isinstance(data["choices"], list) and len(data["choices"]) > 0:
|
||||
choice = data["choices"][0]
|
||||
if isinstance(choice, dict):
|
||||
if "message" in choice and isinstance(choice["message"], dict):
|
||||
content = choice["message"].get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif "text" in choice:
|
||||
content = choice["text"]
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
# Another fallback: direct "content" field
|
||||
if "content" in data:
|
||||
content = data["content"]
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def chat_with_messages(
|
||||
base_url: str,
|
||||
chat_model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
timeout_sec: float = 30.0,
|
||||
extra_options: Optional[Dict[str, Any]] = None,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
thinking: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Send an arbitrary messages array to the LLM and return the raw response JSON.
|
||||
Caller is responsible for interpreting assistant content (including JSON/tool calls).
|
||||
|
||||
Args:
|
||||
base_url: Ollama base URL
|
||||
chat_model: Model name
|
||||
messages: Conversation messages
|
||||
timeout_sec: Request timeout
|
||||
extra_options: Additional model options
|
||||
tools: Optional list of tools in OpenAI-compatible JSON schema format for native tool calling
|
||||
thinking: Enable thinking/reasoning mode
|
||||
|
||||
Returns the parsed JSON response dict on success, or None on error/timeout.
|
||||
"""
|
||||
# Main agentic chat uses 8192 so the system prompt (tool list + protocol
|
||||
# guidance + memory context) doesn't overflow and force ollama to truncate
|
||||
# — which previously dropped the tool schema on smaller models like
|
||||
# gemma4:e2b, tipping them into their pre-trained tool_code scaffolding.
|
||||
payload: Dict[str, Any] = {
|
||||
"model": chat_model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"num_ctx": 8192},
|
||||
"think": thinking,
|
||||
}
|
||||
if extra_options and isinstance(extra_options, dict):
|
||||
# Merge shallowly into options
|
||||
payload["options"].update(extra_options)
|
||||
|
||||
# Add tools for native tool calling support (Ollama 0.4+)
|
||||
if tools and isinstance(tools, list) and len(tools) > 0:
|
||||
payload["tools"] = tools
|
||||
|
||||
try:
|
||||
with requests.post(f"{base_url.rstrip('/')}/api/chat", json=payload, timeout=timeout_sec) as resp:
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except requests.exceptions.Timeout:
|
||||
print(" ⏱️ LLM request timed out", flush=True)
|
||||
return None
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f" ❌ LLM connection error: {e}", flush=True)
|
||||
return None
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# Raise a specific error when the model rejects the tools parameter (HTTP 400).
|
||||
# This lets the caller fall back to text-based tool calling automatically.
|
||||
if e.response is not None and e.response.status_code == 400 and tools:
|
||||
raise ToolsNotSupportedError(
|
||||
f"Model {chat_model!r} returned HTTP 400 — native tools API not supported"
|
||||
)
|
||||
print(f" ❌ LLM HTTP error: {e}", flush=True)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" ❌ LLM error: {e}", flush=True)
|
||||
return None
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user