refactor(arch): modular package structure — split monolithic app.py into config/db/auth/memory/search/rag/gpu + routers/
- config.py: all constants, env vars, limits, skill registry, profiles - db.py: schema init, connection factory, skill state helpers - security.py: PIN hashing, audit logging, rate limiting, CSRF, request helpers - auth.py: session management, PIN verify, auth routes - memory.py: FTS5 CRUD + remember/forget command processing - search.py: SearXNG integration, perplexity scoring, refusal/hedge detection - gpu.py: rocm-smi stats - rag.py: Qdrant vector search + system prompt assembly - routers/: conversations, memories, models, presets, profile, settings, skills, chat, search - app.py: slim entry point, middleware, router registration only Bumps to v1.9.0
This commit is contained in:
0
routers/__init__.py
Normal file
0
routers/__init__.py
Normal file
203
routers/chat.py
Normal file
203
routers/chat.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""JarvisChat routers - /api/chat streaming endpoint."""
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE
|
||||
from db import get_db
|
||||
from memory import process_remember_command
|
||||
from rag import build_system_prompt
|
||||
from search import (calculate_perplexity, is_uncertain, is_refusal,
|
||||
clean_hedging, format_search_results, format_direct_answer,
|
||||
extract_search_query, query_searxng)
|
||||
from security import read_json_body, log_incident, BODY_LIMIT_CHAT_BYTES
|
||||
from config import MAX_CHAT_MESSAGE_CHARS
|
||||
|
||||
log = logging.getLogger("jarvischat")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def parse_llama_stream_chunk(line: str) -> tuple:
|
||||
if line.startswith("data: "):
|
||||
line = line[6:]
|
||||
if line.strip() == "[DONE]":
|
||||
return None, True, {}
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
choices = chunk.get("choices", [])
|
||||
if choices:
|
||||
delta = choices[0].get("delta", {})
|
||||
token = delta.get("content")
|
||||
finish = choices[0].get("finish_reason")
|
||||
stats = {}
|
||||
if finish == "stop":
|
||||
usage = chunk.get("usage", {})
|
||||
stats["tokens_per_sec"] = usage.get("tokens_per_second", 0.0)
|
||||
return token, finish == "stop", stats
|
||||
if "message" in chunk and "content" in chunk["message"]:
|
||||
token = chunk["message"]["content"]
|
||||
done = chunk.get("done", False)
|
||||
stats = {}
|
||||
if done:
|
||||
eval_count = chunk.get("eval_count", 0)
|
||||
eval_duration = chunk.get("eval_duration", 0)
|
||||
stats["tokens_per_sec"] = (eval_count / (eval_duration / 1e9)) if eval_duration > 0 else 0
|
||||
return token, done, stats
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None, False, {}
|
||||
|
||||
|
||||
@router.post("/api/chat")
|
||||
async def chat(request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_CHAT_BYTES)
|
||||
conv_id = body.get("conversation_id")
|
||||
user_message = body.get("message", "").strip()
|
||||
if len(user_message) > MAX_CHAT_MESSAGE_CHARS:
|
||||
raise HTTPException(status_code=413, detail="Chat message is too long")
|
||||
model = body.get("model", DEFAULT_MODEL)
|
||||
preset_prompt = body.get("system_prompt", "")
|
||||
|
||||
if not user_message:
|
||||
raise HTTPException(status_code=400, detail="Empty message")
|
||||
|
||||
db = get_db()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
settings = {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM settings").fetchall()}
|
||||
search_enabled = settings.get("search_enabled", "true") == "true"
|
||||
|
||||
remember_response = process_remember_command(user_message)
|
||||
|
||||
if not conv_id:
|
||||
conv_id = str(uuid.uuid4())
|
||||
title = user_message[:80] + ("..." if len(user_message) > 80 else "")
|
||||
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(conv_id, title, model, now, now))
|
||||
else:
|
||||
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
|
||||
|
||||
db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "user", user_message, now))
|
||||
db.commit()
|
||||
|
||||
history_rows = db.execute(
|
||||
"SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)
|
||||
).fetchall()
|
||||
system_prompt = await build_system_prompt(db, preset_prompt, user_message)
|
||||
db.close()
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
for row in history_rows:
|
||||
messages.append({"role": row["role"], "content": row["content"]})
|
||||
|
||||
ollama_payload = {"model": model, "messages": messages, "stream": True}
|
||||
|
||||
async def stream_response():
|
||||
full_response = []
|
||||
all_logprobs = []
|
||||
tokens_per_sec = 0.0
|
||||
|
||||
if remember_response:
|
||||
yield f"data: {json.dumps({'token': remember_response + chr(10) + chr(10), 'conversation_id': conv_id})}\n\n"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", f"{LLAMA_SERVER_BASE}/v1/chat/completions",
|
||||
json=ollama_payload,
|
||||
timeout=httpx.Timeout(300.0, connect=10.0),
|
||||
) as resp:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.strip():
|
||||
token, done, stats = parse_llama_stream_chunk(line)
|
||||
if token:
|
||||
full_response.append(token)
|
||||
yield f"data: {json.dumps({'token': token, 'conversation_id': conv_id})}\n\n"
|
||||
if done:
|
||||
tokens_per_sec = stats.get("tokens_per_sec", 0.0)
|
||||
|
||||
assistant_msg = "".join(full_response)
|
||||
perplexity = calculate_perplexity(all_logprobs) if all_logprobs else 0.0
|
||||
should_search = is_uncertain(all_logprobs) or is_refusal(assistant_msg)
|
||||
|
||||
if search_enabled and should_search:
|
||||
yield f"data: {json.dumps({'searching': True, 'conversation_id': conv_id})}\n\n"
|
||||
search_query = extract_search_query(user_message)
|
||||
search_results = await query_searxng(search_query)
|
||||
|
||||
if search_results:
|
||||
search_context = format_search_results(search_results)
|
||||
augmented_messages = []
|
||||
if system_prompt:
|
||||
augmented_messages.append({"role": "system", "content": system_prompt + "\n\n" + search_context})
|
||||
else:
|
||||
augmented_messages.append({"role": "system", "content": search_context})
|
||||
for row in history_rows[:-1]:
|
||||
augmented_messages.append({"role": row["role"], "content": row["content"]})
|
||||
augmented_messages.append({"role": "user", "content": user_message})
|
||||
|
||||
yield f"data: {json.dumps({'search_results': len(search_results), 'conversation_id': conv_id})}\n\n"
|
||||
|
||||
augmented_response = []
|
||||
async with client.stream(
|
||||
"POST", f"{LLAMA_SERVER_BASE}/v1/chat/completions",
|
||||
json={"model": model, "messages": augmented_messages, "stream": True},
|
||||
timeout=httpx.Timeout(300.0, connect=10.0),
|
||||
) as resp2:
|
||||
async for line in resp2.aiter_lines():
|
||||
if line.strip():
|
||||
token2, done2, _ = parse_llama_stream_chunk(line)
|
||||
if token2:
|
||||
augmented_response.append(token2)
|
||||
if done2:
|
||||
break
|
||||
|
||||
raw_response = "".join(augmented_response) or assistant_msg
|
||||
cleaned_response = clean_hedging(raw_response)
|
||||
if is_refusal(cleaned_response) or len(cleaned_response) < 20:
|
||||
cleaned_response = format_direct_answer(user_message, search_results)
|
||||
|
||||
yield f"data: {json.dumps({'token': cleaned_response, 'conversation_id': conv_id, 'augmented': True})}\n\n"
|
||||
|
||||
saved_msg = cleaned_response + "\n\n---\n*🔍 Enhanced with web search results*"
|
||||
if remember_response:
|
||||
saved_msg = remember_response + "\n\n" + saved_msg
|
||||
|
||||
db2 = get_db()
|
||||
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat()))
|
||||
db2.commit()
|
||||
db2.close()
|
||||
|
||||
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1)})}\n\n"
|
||||
return
|
||||
|
||||
saved_msg = assistant_msg
|
||||
if remember_response:
|
||||
saved_msg = remember_response + "\n\n" + saved_msg
|
||||
|
||||
db2 = get_db()
|
||||
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat()))
|
||||
db2.commit()
|
||||
db2.close()
|
||||
|
||||
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1)})}\n\n"
|
||||
|
||||
except httpx.RemoteProtocolError:
|
||||
pass
|
||||
except httpx.ConnectError:
|
||||
yield f"data: {json.dumps({'error': 'Cannot connect to Ollama. Is it running?'})}\n\n"
|
||||
except Exception as e:
|
||||
incident_key = log_incident("chat_stream", message="Ollama stream failure during chat response",
|
||||
request=request, exc=e)
|
||||
yield f"data: {json.dumps({'error': 'Chat response generation failed before completion. Use the incident key for support lookup.', 'error_key': incident_key})}\n\n"
|
||||
|
||||
return StreamingResponse(stream_response(), media_type="text/event-stream")
|
||||
83
routers/conversations.py
Normal file
83
routers/conversations.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""JarvisChat routers - Conversation CRUD."""
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from db import get_db
|
||||
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
||||
from config import DEFAULT_MODEL, MAX_CONVERSATION_TITLE_CHARS
|
||||
|
||||
log = logging.getLogger("jarvischat")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/conversations")
|
||||
async def list_conversations():
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT * FROM conversations ORDER BY updated_at DESC").fetchall()
|
||||
db.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/conversations")
|
||||
async def create_conversation(request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
conv_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
model = body.get("model", DEFAULT_MODEL)
|
||||
title = str(body.get("title", "New Chat"))[:MAX_CONVERSATION_TITLE_CHARS]
|
||||
db = get_db()
|
||||
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(conv_id, title, model, now, now))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"id": conv_id, "title": title, "model": model, "created_at": now, "updated_at": now}
|
||||
|
||||
|
||||
@router.get("/api/conversations/{conv_id}")
|
||||
async def get_conversation(conv_id: str):
|
||||
db = get_db()
|
||||
conv = db.execute("SELECT * FROM conversations WHERE id = ?", (conv_id,)).fetchone()
|
||||
if not conv:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
messages = db.execute("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)).fetchall()
|
||||
db.close()
|
||||
return {"conversation": dict(conv), "messages": [dict(m) for m in messages]}
|
||||
|
||||
|
||||
@router.put("/api/conversations/{conv_id}")
|
||||
async def update_conversation(conv_id: str, request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
db = get_db()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if "title" in body:
|
||||
db.execute("UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?",
|
||||
(str(body["title"])[:MAX_CONVERSATION_TITLE_CHARS], now, conv_id))
|
||||
if "model" in body:
|
||||
db.execute("UPDATE conversations SET model = ?, updated_at = ? WHERE id = ?",
|
||||
(body["model"], now, conv_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/conversations/{conv_id}")
|
||||
async def delete_conversation(conv_id: str):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM messages WHERE conversation_id = ?", (conv_id,))
|
||||
db.execute("DELETE FROM conversations WHERE id = ?", (conv_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/conversations")
|
||||
async def delete_all_conversations():
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM messages")
|
||||
db.execute("DELETE FROM conversations")
|
||||
db.commit()
|
||||
db.close()
|
||||
log.info("Deleted all conversations")
|
||||
return {"status": "ok"}
|
||||
63
routers/memories.py
Normal file
63
routers/memories.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""JarvisChat routers - Memory CRUD API."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from typing import Optional
|
||||
|
||||
from db import get_db
|
||||
from memory import add_memory, delete_memory, update_memory, get_all_memories, search_memories
|
||||
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
||||
from config import MAX_MEMORY_FACT_CHARS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/memories")
|
||||
async def list_memories(topic: Optional[str] = None):
|
||||
memories = get_all_memories(topic)
|
||||
return {"memories": memories, "count": len(memories)}
|
||||
|
||||
|
||||
@router.post("/api/memories")
|
||||
async def create_memory(request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
fact = str(body.get("fact", "")).strip()
|
||||
if not fact:
|
||||
raise HTTPException(status_code=400, detail="Memory fact is required")
|
||||
if len(fact) > MAX_MEMORY_FACT_CHARS:
|
||||
raise HTTPException(status_code=413, detail="Memory fact is too long")
|
||||
rowid = add_memory(fact=fact, topic=body.get("topic", "general"), source=body.get("source", "manual"))
|
||||
return {"rowid": rowid, "status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/memories/{rowid}")
|
||||
async def remove_memory(rowid: int):
|
||||
if not delete_memory(rowid):
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.put("/api/memories/{rowid}")
|
||||
async def edit_memory(rowid: int, request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
fact = str(body.get("fact", "")).strip()
|
||||
if not fact:
|
||||
raise HTTPException(status_code=400, detail="Memory fact is required")
|
||||
if len(fact) > MAX_MEMORY_FACT_CHARS:
|
||||
raise HTTPException(status_code=413, detail="Memory fact is too long")
|
||||
if not update_memory(rowid, fact):
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/api/memories/search")
|
||||
async def search_memories_api(q: str, limit: int = 10):
|
||||
results = search_memories(q, limit=limit)
|
||||
return {"results": results, "count": len(results)}
|
||||
|
||||
|
||||
@router.get("/api/memories/stats")
|
||||
async def memory_stats():
|
||||
db = get_db()
|
||||
total = db.execute("SELECT COUNT(*) as c FROM memories").fetchone()["c"]
|
||||
topics = db.execute("SELECT topic, COUNT(*) as c FROM memories GROUP BY topic ORDER BY c DESC").fetchall()
|
||||
db.close()
|
||||
return {"total": total, "by_topic": {row["topic"]: row["c"] for row in topics}}
|
||||
78
routers/models.py
Normal file
78
routers/models.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
JarvisChat routers - Model listing, system stats.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from config import OLLAMA_BASE
|
||||
from gpu import get_gpu_stats
|
||||
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
||||
|
||||
log = logging.getLogger("jarvischat")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/models")
|
||||
async def list_models():
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(f"{OLLAMA_BASE}/v1/models", timeout=10)
|
||||
data = resp.json()
|
||||
models = [{"name": m["id"], "model": m["id"]} for m in data.get("data", [])]
|
||||
return {"models": models}
|
||||
except httpx.ConnectError:
|
||||
raise HTTPException(status_code=502, detail="Cannot connect to llama-server.")
|
||||
|
||||
|
||||
@router.get("/api/ps")
|
||||
async def running_models():
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(f"{OLLAMA_BASE}/api/ps", timeout=10)
|
||||
return resp.json()
|
||||
except httpx.ConnectError:
|
||||
raise HTTPException(status_code=502, detail="Cannot connect to Ollama.")
|
||||
|
||||
|
||||
@router.post("/api/show")
|
||||
async def show_model(request: Request):
|
||||
from security import BODY_LIMIT_DEFAULT_BYTES
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.post(f"{OLLAMA_BASE}/api/show", json=body, timeout=10)
|
||||
return resp.json()
|
||||
except httpx.ConnectError:
|
||||
raise HTTPException(status_code=502, detail="Cannot connect to Ollama.")
|
||||
|
||||
|
||||
@router.get("/api/stats")
|
||||
async def system_stats():
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||
memory = psutil.virtual_memory()
|
||||
gpu = get_gpu_stats()
|
||||
return {
|
||||
"cpu_percent": round(cpu_percent, 1),
|
||||
"memory_percent": round(memory.percent, 1),
|
||||
"memory_used_gb": round(memory.used / (1024**3), 1),
|
||||
"memory_total_gb": round(memory.total / (1024**3), 1),
|
||||
"gpu_percent": gpu["gpu_percent"],
|
||||
"vram_percent": gpu["vram_percent"],
|
||||
"gpu_available": gpu["available"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/search/status")
|
||||
async def search_status():
|
||||
from config import SEARXNG_BASE
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(f"{SEARXNG_BASE}/search",
|
||||
params={"q": "test", "format": "json"}, timeout=5)
|
||||
return {"available": resp.status_code == 200}
|
||||
except Exception:
|
||||
return {"available": False}
|
||||
61
routers/presets.py
Normal file
61
routers/presets.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""JarvisChat routers - System prompt presets."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from db import get_db
|
||||
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
||||
from config import MAX_PRESET_NAME_CHARS, MAX_PRESET_PROMPT_CHARS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/presets")
|
||||
async def list_presets():
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT * FROM system_presets ORDER BY is_default DESC, name ASC").fetchall()
|
||||
db.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/presets")
|
||||
async def create_preset(request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
name = str(body.get("name", "")).strip()
|
||||
prompt = str(body.get("prompt", "")).strip()
|
||||
if not name or not prompt:
|
||||
raise HTTPException(status_code=400, detail="Preset name and prompt are required")
|
||||
if len(name) > MAX_PRESET_NAME_CHARS or len(prompt) > MAX_PRESET_PROMPT_CHARS:
|
||||
raise HTTPException(status_code=413, detail="Preset fields are too long")
|
||||
preset_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
db = get_db()
|
||||
db.execute("INSERT INTO system_presets (id, name, prompt, is_default, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||
(preset_id, name, prompt, now))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"id": preset_id, "name": name, "prompt": prompt}
|
||||
|
||||
|
||||
@router.put("/api/presets/{preset_id}")
|
||||
async def update_preset(preset_id: str, request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
name = str(body.get("name", "")).strip()
|
||||
prompt = str(body.get("prompt", "")).strip()
|
||||
if not name or not prompt:
|
||||
raise HTTPException(status_code=400, detail="Preset name and prompt are required")
|
||||
if len(name) > MAX_PRESET_NAME_CHARS or len(prompt) > MAX_PRESET_PROMPT_CHARS:
|
||||
raise HTTPException(status_code=413, detail="Preset fields are too long")
|
||||
db = get_db()
|
||||
db.execute("UPDATE system_presets SET name = ?, prompt = ? WHERE id = ?", (name, prompt, preset_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/presets/{preset_id}")
|
||||
async def delete_preset(preset_id: str):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM system_presets WHERE id = ? AND is_default = 0", (preset_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"status": "ok"}
|
||||
36
routers/profile.py
Normal file
36
routers/profile.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""JarvisChat routers - Profile."""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from db import get_db
|
||||
from security import read_json_body, BODY_LIMIT_PROFILE_BYTES
|
||||
from config import MAX_PROFILE_CHARS, DEFAULT_PROFILE
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/profile")
|
||||
async def get_profile():
|
||||
db = get_db()
|
||||
row = db.execute("SELECT content, updated_at FROM profile WHERE id = 1").fetchone()
|
||||
db.close()
|
||||
return ({"content": row["content"], "updated_at": row["updated_at"]} if row
|
||||
else {"content": "", "updated_at": ""})
|
||||
|
||||
|
||||
@router.put("/api/profile")
|
||||
async def update_profile(request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_PROFILE_BYTES)
|
||||
content = str(body.get("content", ""))
|
||||
if len(content) > MAX_PROFILE_CHARS:
|
||||
raise HTTPException(status_code=413, detail="Profile content is too long")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
db = get_db()
|
||||
db.execute("UPDATE profile SET content = ?, updated_at = ? WHERE id = 1", (content, now))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"status": "ok", "updated_at": now}
|
||||
|
||||
|
||||
@router.get("/api/profile/default")
|
||||
async def get_default_profile():
|
||||
return {"content": DEFAULT_PROFILE}
|
||||
108
routers/search_route.py
Normal file
108
routers/search_route.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""JarvisChat routers - /api/search explicit search endpoint."""
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, MAX_SEARCH_QUERY_CHARS
|
||||
from db import get_db
|
||||
from search import query_searxng, format_search_results
|
||||
from routers.chat import parse_llama_stream_chunk
|
||||
from security import read_json_body, log_incident, BODY_LIMIT_CHAT_BYTES
|
||||
|
||||
log = logging.getLogger("jarvischat")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/search")
|
||||
async def explicit_search(request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_CHAT_BYTES)
|
||||
query = body.get("query", "").strip()
|
||||
if len(query) > MAX_SEARCH_QUERY_CHARS:
|
||||
raise HTTPException(status_code=413, detail="Search query is too long")
|
||||
conv_id = body.get("conversation_id")
|
||||
model = body.get("model", DEFAULT_MODEL)
|
||||
|
||||
if not query:
|
||||
raise HTTPException(status_code=400, detail="Empty query")
|
||||
|
||||
db = get_db()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if not conv_id:
|
||||
conv_id = str(uuid.uuid4())
|
||||
title = f"🔍 {query[:70]}..." if len(query) > 70 else f"🔍 {query}"
|
||||
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(conv_id, title, model, now, now))
|
||||
else:
|
||||
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
|
||||
|
||||
db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "user", f"🔍 {query}", now))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
async def stream_search():
|
||||
yield f"data: {json.dumps({'conversation_id': conv_id, 'searching': True})}\n\n"
|
||||
|
||||
results = await query_searxng(query, max_results=5)
|
||||
|
||||
if not results:
|
||||
error_msg = "No search results found."
|
||||
yield f"data: {json.dumps({'token': error_msg, 'conversation_id': conv_id})}\n\n"
|
||||
db2 = get_db()
|
||||
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", error_msg, datetime.now(timezone.utc).isoformat()))
|
||||
db2.commit()
|
||||
db2.close()
|
||||
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'search_results': len(results), 'conversation_id': conv_id})}\n\n"
|
||||
|
||||
search_context = format_search_results(results)
|
||||
messages = [
|
||||
{"role": "system", "content": f"You have access to current web data. Answer directly using ONLY the data below. Be concise. No apologies. No disclaimers.\n\n{search_context}"},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
|
||||
full_response = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", f"{LLAMA_SERVER_BASE}/v1/chat/completions",
|
||||
json={"model": model, "messages": messages, "stream": True},
|
||||
timeout=httpx.Timeout(300.0, connect=10.0),
|
||||
) as resp:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.strip():
|
||||
token, done, _ = parse_llama_stream_chunk(line)
|
||||
if token:
|
||||
full_response.append(token)
|
||||
yield f"data: {json.dumps({'token': token, 'conversation_id': conv_id})}\n\n"
|
||||
if done:
|
||||
break
|
||||
except Exception as e:
|
||||
incident_key = log_incident("search_summarization_stream",
|
||||
message="Stream failure during explicit search summarization",
|
||||
request=request, exc=e)
|
||||
yield f"data: {json.dumps({'error': 'Search summarization could not complete right now.', 'error_key': incident_key})}\n\n"
|
||||
return
|
||||
|
||||
summary = "".join(full_response)
|
||||
saved_msg = f"{summary}\n\n---\n*🔍 Web search results*"
|
||||
|
||||
db2 = get_db()
|
||||
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat()))
|
||||
db2.commit()
|
||||
db2.close()
|
||||
|
||||
yield f"data: {json.dumps({'raw_results': results, 'conversation_id': conv_id})}\n\n"
|
||||
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True})}\n\n"
|
||||
|
||||
return StreamingResponse(stream_search(), media_type="text/event-stream")
|
||||
36
routers/settings.py
Normal file
36
routers/settings.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""JarvisChat routers - Settings."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from db import get_db
|
||||
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
||||
from config import MAX_SETTINGS_KEYS, MAX_SETTINGS_VALUE_CHARS, ALLOWED_SETTINGS_KEYS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/settings")
|
||||
async def get_settings():
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT key, value FROM settings").fetchall()
|
||||
db.close()
|
||||
return {row["key"]: row["value"] for row in rows}
|
||||
|
||||
|
||||
@router.put("/api/settings")
|
||||
async def update_settings(request: Request):
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=400, detail="Settings payload must be an object")
|
||||
if len(body) > MAX_SETTINGS_KEYS:
|
||||
raise HTTPException(status_code=413, detail="Too many settings in one request")
|
||||
unknown_keys = sorted(key for key in body.keys() if str(key) not in ALLOWED_SETTINGS_KEYS)
|
||||
if unknown_keys:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting key(s): {', '.join(unknown_keys)}")
|
||||
db = get_db()
|
||||
for key, value in body.items():
|
||||
if len(str(key)) > 80 or len(str(value)) > MAX_SETTINGS_VALUE_CHARS:
|
||||
db.close()
|
||||
raise HTTPException(status_code=413, detail="Setting key/value too long")
|
||||
db.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, str(value)))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"status": "ok"}
|
||||
42
routers/skills.py
Normal file
42
routers/skills.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""JarvisChat routers - Skills."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from db import get_db, get_setting, list_skills_with_state, set_skill_enabled
|
||||
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
||||
from config import MAX_SKILL_KEY_CHARS, SKILLS_BY_KEY
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/skills")
|
||||
async def list_skills():
|
||||
db = get_db()
|
||||
skills = list_skills_with_state(db)
|
||||
db.close()
|
||||
return {"skills": skills, "count": len(skills)}
|
||||
|
||||
|
||||
@router.get("/api/skills/active")
|
||||
async def list_active_skills():
|
||||
db = get_db()
|
||||
skills_enabled = get_setting(db, "skills_enabled", "true") == "true"
|
||||
skills = list_skills_with_state(db)
|
||||
db.close()
|
||||
active = [s for s in skills if s["enabled"]] if skills_enabled else []
|
||||
return {"skills": active, "count": len(active), "skills_enabled": skills_enabled}
|
||||
|
||||
|
||||
@router.put("/api/skills/{skill_key}")
|
||||
async def update_skill(skill_key: str, request: Request):
|
||||
skill_key = skill_key.strip()
|
||||
if len(skill_key) > MAX_SKILL_KEY_CHARS or skill_key not in SKILLS_BY_KEY:
|
||||
raise HTTPException(status_code=404, detail="Unknown skill")
|
||||
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||
if "enabled" not in body or not isinstance(body.get("enabled"), bool):
|
||||
raise HTTPException(status_code=400, detail="Field 'enabled' (boolean) is required")
|
||||
db = get_db()
|
||||
set_skill_enabled(db, skill_key, bool(body["enabled"]))
|
||||
db.commit()
|
||||
skills = list_skills_with_state(db)
|
||||
db.close()
|
||||
updated = next((s for s in skills if s["key"] == skill_key), None)
|
||||
return {"status": "ok", "skill": updated}
|
||||
Reference in New Issue
Block a user