Subject-Emu-5259 commited on
Commit
17814b8
·
verified ·
1 Parent(s): 80bdf6c

sync: update services/webui_service.py

Browse files
Files changed (1) hide show
  1. services/webui_service.py +1744 -73
services/webui_service.py CHANGED
@@ -7,28 +7,176 @@ NeuralAI Unified Service - ALL IN ONE
7
  - Tools (code, terminal)
8
  - Web UI
9
  """
10
- import os, sys, json, asyncio, requests
11
- import torch, sqlite3, subprocess, tempfile, uuid
12
  from pathlib import Path
13
- from datetime import datetime
14
- from flask import Flask, Response, jsonify, request, send_from_directory
 
 
 
 
15
 
16
- torch.set_num_threads(4)
 
 
 
 
 
17
 
18
  app = Flask(__name__, static_folder=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  # Config
21
  PORT = int(os.environ.get("PORT", "5000"))
22
  MODEL_PATH = os.environ.get("MODEL_PATH", "/home/workspace/Projects/NeuralAI/checkpoints/v2_model")
23
  BASE_MODEL = os.environ.get("BASE_MODEL", "HuggingFaceTB/SmolLM2-360M-Instruct")
24
- STATIC_PATH = "/home/workspace/Projects/NeuralAI/from-scratch/web_ui"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
- # Model globals
27
  model = None
28
  tokenizer = None
29
- model_status = "loading"
 
 
 
 
 
 
 
30
  inference_count = 0
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  # Terminal sessions
33
  terminal_sessions = {}
34
  # Conversations storage (Simple JSON file)
@@ -37,6 +185,129 @@ CONV_FILE = Path("/home/workspace/Projects/NeuralAI/conversations.json")
37
  STORAGE_SERVICE = os.environ.get("STORAGE_SERVICE", "http://localhost:7003")
38
  STORAGE_ROOT = Path("/home/workspace/Projects/NeuralAI/storage")
39
  STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  def load_convs():
42
  if CONV_FILE.exists():
@@ -59,20 +330,125 @@ UPLINK_AGENTS = {
59
  # ====================
60
  # MODEL LOADING
61
  # ====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def load_model():
63
  global model, tokenizer, model_status
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  try:
 
 
65
  from transformers import AutoModelForCausalLM, AutoTokenizer
66
  from peft import PeftModel
67
  tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
68
  tokenizer.pad_token = tokenizer.eos_token
69
  adapter = Path(MODEL_PATH)
70
  has_adapter = any((adapter / f).exists() for f in ["adapter_model.bin", "adapter_model.safetensors"])
 
 
71
  if adapter.exists() and has_adapter:
72
- base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=torch.float32, device_map=None)
73
- model = PeftModel.from_pretrained(base, str(adapter))
74
  else:
75
- model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=torch.float32, device_map=None)
76
  model.eval()
77
  model_status = "ready"
78
  print(f"[OK] Model loaded. Params: {sum(p.numel() for p in model.parameters()):,}")
@@ -80,30 +456,425 @@ def load_model():
80
  model_status = f"error: {e}"
81
  print(f"[ERROR] Model: {e}")
82
 
83
- def generate_response(prompt, max_tokens=256, temperature=0.7):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  global model, tokenizer, inference_count
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  if model is None or tokenizer is None:
86
- return "Model not loaded."
87
  try:
88
- full = f"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
89
  inputs = tokenizer(full, return_tensors="pt")
 
 
 
 
 
 
90
  with torch.no_grad():
91
- out = model.generate(**inputs, max_new_tokens=max_tokens, do_sample=True, temperature=temperature, top_p=0.95, pad_token_id=tokenizer.eos_token_id)
 
 
 
 
 
 
 
 
92
  new_tokens = out[0][inputs["input_ids"].shape[-1]:]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  inference_count += 1
94
- return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
95
  except Exception as e:
96
- return f"Error: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  # ====================
99
  # ROUTES - STATIC
100
  # ====================
 
 
 
101
  @app.route("/")
102
  def index():
103
  p = f"{STATIC_PATH}/templates/index.html"
104
  if os.path.exists(p):
105
  with open(p) as f:
106
- return f.read(), 200, {"Content-Type": "text/html"}
 
 
 
 
 
 
 
 
107
  return "index.html not found", 404
108
 
109
  @app.route("/<path:path>")
@@ -113,7 +884,9 @@ def static_files(path):
113
  if os.path.exists(p) and os.path.isfile(p):
114
  ext = path.split('.')[-1]
115
  ct = {"js": "application/javascript", "css": "text/css", "png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"}
116
- return send_from_directory(os.path.dirname(p), os.path.basename(p), mimetype=ct.get(ext, "text/plain"))
 
 
117
  return "Not found", 404
118
 
119
  # ====================
@@ -135,12 +908,49 @@ def terms():
135
  return f.read(), 200, {"Content-Type": "text/html"}
136
  return "Terms of service not found", 404
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  # ====================
139
  # ROUTES - HEALTH
140
  # ====================
141
  @app.route("/health")
 
 
142
  def health():
143
- return jsonify({"status": model_status, "model": BASE_MODEL, "inference_count": inference_count, "uplink": "integrated"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  # ====================
146
  # ROUTES - MODEL
@@ -165,11 +975,42 @@ def generate_stream():
165
 
166
  # Unified AI API for Frontend
167
  @app.route("/api/chat", methods=["POST"])
168
- def api_chat():
 
 
169
  data = request.get_json() or {}
170
  prompt = data.get("prompt", "")
171
  use_uplink = data.get("use_uplink", False)
172
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def generate_unified():
174
  if use_uplink:
175
  for agent_name, agent in UPLINK_AGENTS.items():
@@ -180,38 +1021,104 @@ def api_chat():
180
  yield f"data: {json.dumps({'content': chunk})}\n\n"
181
  except: pass
182
  else:
183
- response = generate_response(prompt)
184
- for word in response.split():
185
- yield f"data: {json.dumps({'content': word + ' '})}\n\n"
186
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  yield "data: [DONE]\n\n"
188
 
189
  return Response(generate_unified(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  # ====================
192
  # ROUTES - CONVERSATIONS
193
  # ====================
194
  @app.route("/api/conversations", methods=["GET", "POST"])
195
- def manage_convs():
196
- convs = load_convs()
197
- if request.method == "POST":
198
- data = request.get_json() or {}
199
- cid = str(uuid.uuid4())[:8]
200
- convs[cid] = {"title": data.get("title", "New Chat"), "messages": []}
201
- save_convs(convs)
202
- return jsonify({"success": True, "id": cid})
203
-
204
- return jsonify([{"id": k, "title": v["title"]} for k, v in convs.items()])
205
-
206
- @app.route("/api/conversations/<cid>", methods=["GET", "DELETE"])
207
- def conv_detail(cid):
208
- convs = load_convs()
209
- if cid not in convs: return jsonify({"error": "Not found"}), 404
210
- if request.method == "DELETE":
211
- del convs[cid]
212
- save_convs(convs)
213
- return jsonify({"success": True})
214
- return jsonify(convs[cid])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
  # ====================
217
  # ROUTES - FILES (Proxied to Storage Service)
@@ -222,39 +1129,57 @@ def manage_files():
222
  if request.method == "POST":
223
  if 'file' not in request.files: return jsonify({"error": "No file"}), 400
224
  file = request.files['file']
225
- files = {'file': (file.filename, file.read(), file.content_type)}
226
- r = requests.post(f"{STORAGE_SERVICE}/api/storage/upload", files=files)
227
- return jsonify(r.json()), r.status_code
228
-
229
- r = requests.get(f"{STORAGE_SERVICE}/api/storage/list")
230
- if r.status_code == 200:
231
- data = r.json()
232
- legacy_files = []
233
- for item in data.get("items", []):
234
- legacy_files.append({
235
- "name": item["name"],
236
- "size": item["size"],
237
- "path": item["name"],
238
- "is_dir": item["is_dir"]
239
- })
240
- return jsonify(legacy_files)
241
- return jsonify(r.json()), r.status_code
242
- except Exception as e:
243
- print(f"[WARN] Storage service down: {e}")
244
  files = []
245
- for f in STORAGE_ROOT.iterdir():
246
- files.append({"name": f.name, "size": f.stat().st_size, "path": f.name})
 
 
 
 
 
 
 
 
247
  return jsonify(files)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
  @app.route("/api/files/<path:filename>", methods=["GET", "DELETE"])
250
  def handle_file(filename):
251
  try:
 
 
 
252
  if request.method == "DELETE":
253
- r = requests.delete(f"{STORAGE_SERVICE}/api/storage/delete", params={"path": filename})
254
- return jsonify(r.json()), r.status_code
255
-
256
- r = requests.get(f"{STORAGE_SERVICE}/api/storage/download", params={"path": filename}, stream=True)
257
- return Response(r.iter_content(chunk_size=1024), content_type=r.headers.get('Content-Type'))
 
 
 
 
 
 
 
258
  except Exception as e:
259
  return jsonify({"error": str(e)}), 500
260
 
@@ -308,10 +1233,756 @@ def execute_code():
308
  finally:
309
  os.unlink(path)
310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  # ====================
312
  # STARTUP
313
  # ====================
314
  if __name__ == "__main__":
315
  print(f"NeuralAI Unified Service starting on port {PORT}...")
316
- load_model()
 
 
 
 
 
 
 
 
317
  app.run(host="0.0.0.0", port=PORT, debug=False, threaded=True)
 
7
  - Tools (code, terminal)
8
  - Web UI
9
  """
10
+ import os, sys, json, asyncio, requests, logging, threading, secrets, re
11
+ import sqlite3, subprocess, tempfile, uuid, jwt
12
  from pathlib import Path
13
+ from datetime import datetime, timedelta, timezone
14
+ from functools import wraps
15
+ from werkzeug.security import generate_password_hash, check_password_hash
16
+ from flask import Flask, Response, jsonify, request, send_from_directory, stream_with_context
17
+ from flask_sock import Sock
18
+ import websocket # websocket-client for proxying
19
 
20
+ logging.basicConfig(level=logging.INFO)
21
+ logger = logging.getLogger("NeuralAI")
22
+
23
+ # torch is imported lazily inside load_model() only when LLM_BACKEND=local
24
+ # This prevents 6GB+ RAM usage on ZO Computer when using external API backends
25
+ torch = None
26
 
27
  app = Flask(__name__, static_folder=None)
28
+ app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "neural-ai-multi-layer-secure-secret-key-2026-v5-stable")
29
+ # === CORS for BYO API (OpenAI-compatible) endpoints ===
30
+ # Lets other chat UIs (e.g. ZO Computer's Bring Your Own Key) call
31
+ # /v1/chat/completions and /v1/models — including browser-side / preflight.
32
+ @app.after_request
33
+ def _add_cors_headers(resp):
34
+ p = request.path
35
+ if p.startswith("/v1") or p.startswith("/api/settings/api-key"):
36
+ resp.headers["Access-Control-Allow-Origin"] = "*"
37
+ resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
38
+ resp.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, X-Api-Key"
39
+ resp.headers["Access-Control-Expose-Headers"] = "Content-Type, X-Request-Id"
40
+ resp.headers["Access-Control-Max-Age"] = "86400"
41
+ return resp
42
 
43
  # Config
44
  PORT = int(os.environ.get("PORT", "5000"))
45
  MODEL_PATH = os.environ.get("MODEL_PATH", "/home/workspace/Projects/NeuralAI/checkpoints/v2_model")
46
  BASE_MODEL = os.environ.get("BASE_MODEL", "HuggingFaceTB/SmolLM2-360M-Instruct")
47
+ STATIC_PATH = os.environ.get("STATIC_PATH", "/home/workspace/Projects/NeuralAI/from-scratch/web_ui")
48
+ # Zo Computer API identity token (used by the host's native image generator)
49
+ ZO_API_TOKEN = os.environ.get("ZO_API_TOKEN", os.environ.get("ZO_CLIENT_IDENTITY_TOKEN", ""))
50
+ DATA_DIR = Path("/home/workspace/Projects/NeuralAI/data")
51
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
52
+ DATABASE = str(DATA_DIR / "neuralai.db")
53
+ # Repository root (parent of services/) — used by the self-update endpoint
54
+ REPO_ROOT = Path(__file__).resolve().parent.parent
55
+ # Founder account — auto-promoted to founder on login/signup
56
+ FOUNDER_EMAIL = os.environ.get("FOUNDER_EMAIL", "deandrewh26@gmail.com")
57
+
58
+ # ====================
59
+ # LLM BACKEND CONFIG
60
+ # ====================
61
+ # On ZO Computer (4 GB RAM): PyTorch + SmolLM2-360M = ~6.2 GB → OOM kill loop (this paused the service).
62
+ # LOCAL: LM Studio (llama.cpp) on localhost:1234 — SmolLM2-360M, ~260 MB RAM, no OOM, no external cost.
63
+ # ZO: REMOVED. No fallback to ZO /zo/ask — local model only.
64
+ # Override with env vars: LLM_BACKEND, LLM_API_URL, LLM_MODEL, LLM_API_KEY.
65
+ _is_zo = bool(os.environ.get("ZO_CLIENT_IDENTITY_TOKEN"))
66
+ LLM_BACKEND = os.environ.get("LLM_BACKEND", "openai_compatible") # LOCAL LM Studio on :1234 — no ZO fallback
67
+ LLM_API_URL = os.environ.get("LLM_API_URL", "")
68
+ LLM_MODEL = os.environ.get("LLM_MODEL", "byok:0d3567f7-f521-42b0-8adf-65c9b036cf89") # user's NeuralAI model (HY3) — avoids 402 free-allowance errors
69
+ LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
70
+ _USE_FLOAT16 = _is_zo or os.environ.get("NEURALAI_FLOAT16", "").lower() in ("1", "true", "yes")
71
+
72
+ # === ZO Computer: default to ZO /zo/ask with the user's BYOK model (no OOM, no 402) ===
73
+ if _is_zo:
74
+ # On the 4GB ZO Computer, loading the local PyTorch model OOMs (watchdog hits 100% RAM and
75
+ # the supervisor pauses the service) and the 360M model produces incoherent <80-token replies.
76
+ # We default to ZO native inference using the user's BYOK model (HY3) so chat + /v1/chat/completions
77
+ # serve a real model with zero local RAM. LOCAL PyTorch stays available via LLM_BACKEND=local
78
+ # on machines with >=8GB RAM. Explicit overrides are still honored.
79
+ # Priority: explicit env override > LOCAL LM Studio (:1234) > none.
80
+ if LLM_BACKEND == "":
81
+ LLM_BACKEND = "openai_compatible" # LOCAL LM Studio on :1234 — no ZO fallback
82
+ LLM_API_URL = LLM_API_URL or "http://localhost:1234/v1"
83
+ logger.info(f"[BOOT] ZO Computer detected — defaulting to LOCAL LM Studio (:1234).")
84
+ elif LLM_BACKEND == "local":
85
+ # Explicit local PyTorch requested — honor it (float16 keeps it under 4 GB).
86
+ logger.info("[BOOT] ZO Computer: explicit local backend requested — loading PyTorch model in float16.")
87
+ elif LLM_BACKEND == "llmster":
88
+ LLM_API_URL = LLM_API_URL or "http://localhost:1234/v1"
89
+ LLM_BACKEND = "openai_compatible"
90
+ logger.info(f"[BOOT] ZO Computer: llmster fallback at {LLM_API_URL}")
91
+ elif LLM_BACKEND == "none":
92
+ logger.info("[BOOT] ZO Computer: lightweight mode (no inference backend)")
93
+ # If user explicitly set openai_compatible or zo via env, respect it
94
+ else:
95
+ logger.info(f"[BOOT] ZO Computer: using explicit backend={LLM_BACKEND} model={LLM_MODEL}")
96
 
97
+ # Model globals (PyTorch) — only loaded when LLM_BACKEND=local
98
  model = None
99
  tokenizer = None
100
+ if LLM_BACKEND == "local":
101
+ model_status = "loading"
102
+ elif LLM_BACKEND == "zo":
103
+ # Only ZO native /zo/ask relay is a truly external cloud backend
104
+ model_status = "ready (external backend)"
105
+ else:
106
+ # openai_compatible / lmstudio / ollama -> local inference server (e.g. LM Studio :1234)
107
+ model_status = "ready"
108
  inference_count = 0
109
 
110
+ # Streaming abort control: conv_id -> threading.Event
111
+ stop_events = {}
112
+
113
+ # ====================
114
+ # DEFENSE 1: KEEP-ALIVE PINGER
115
+ # ====================
116
+ # Prevents ZO Computer from putting the service to sleep by pinging /health
117
+ # every 5 minutes in a background thread.
118
+ def _keep_alive_pinger():
119
+ """Background thread: pings own /health endpoint every 5 min to prevent ZO sleep.
120
+
121
+ Hits the PUBLIC service URL when NEURALAI_PUBLIC_URL is set (real external
122
+ ingress, so the ZO Computer sandbox is not idled/slept by the platform), and
123
+ falls back to localhost on any failure so the process stays self-warm too.
124
+ """
125
+ import urllib.request
126
+ public_url = (os.environ.get("NEURALAI_PUBLIC_URL") or "").rstrip("/")
127
+ while True:
128
+ try:
129
+ time.sleep(300) # 5 minutes
130
+ targets = []
131
+ if public_url:
132
+ targets.append(f"{public_url}/health")
133
+ targets.append(f"http://127.0.0.1:{PORT}/health")
134
+ ok = False
135
+ for t in targets:
136
+ try:
137
+ urllib.request.urlopen(t, timeout=10)
138
+ logger.info(f"[KEEPALIVE] Health ping OK -> {t}")
139
+ ok = True
140
+ break
141
+ except Exception as inner_e:
142
+ logger.warning(f"[KEEPALIVE] Ping failed ({t}): {inner_e}")
143
+ if not ok:
144
+ logger.warning("[KEEPALIVE] All ping targets failed (non-fatal)")
145
+ except Exception as e:
146
+ logger.warning(f"[KEEPALIVE] Ping loop error (non-fatal): {e}")
147
+
148
+ # ====================
149
+ # DEFENSE 2: MEMORY WATCHDOG
150
+ # ====================
151
+ # Monitors AVAILABLE RAM (not %). The loaded model legitimately uses most of the
152
+ # 4 GB on ZO, so a high % is normal and must NOT pause the service. Only a truly
153
+ # low amount of reclaimable memory (<50 MB) is treated as critical.
154
+ def _memory_watchdog():
155
+ """Background thread: monitors system memory every 60s."""
156
+ global model_status
157
+ while True:
158
+ try:
159
+ time.sleep(60)
160
+ import re as _re
161
+ with open("/proc/meminfo") as f:
162
+ mem = f.read()
163
+ total = int(_re.search(r'MemTotal:\s+(\d+)', mem).group(1))
164
+ avail = int(_re.search(r'MemAvailable:\s+(\d+)', mem).group(1))
165
+ avail_mb = avail / 1024
166
+ used_pct = (1 - avail / total) * 100
167
+ # The loaded model legitimately uses nearly all RAM on the 4 GB ZO host;
168
+ # gVisor often reports MemAvailable near 0 even while inference works fine.
169
+ # We only GC + log here — we NEVER flip model_status to "overloaded",
170
+ # because that 503s every request (incl. chat) and the host then sleeps
171
+ # the service, which is the root cause of the recurring pauses.
172
+ if avail_mb < 150:
173
+ logger.warning(f"[WATCHDOG] Low reclaimable memory: {avail_mb:.0f}MB available ({used_pct:.0f}% used) — running GC")
174
+ import gc; gc.collect()
175
+ else:
176
+ logger.debug(f"[WATCHDOG] Memory OK: {avail_mb:.0f}MB available")
177
+ except Exception:
178
+ pass # /proc not available (non-Linux), skip silently
179
+
180
  # Terminal sessions
181
  terminal_sessions = {}
182
  # Conversations storage (Simple JSON file)
 
185
  STORAGE_SERVICE = os.environ.get("STORAGE_SERVICE", "http://localhost:7003")
186
  STORAGE_ROOT = Path("/home/workspace/Projects/NeuralAI/storage")
187
  STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
188
+ from collections import defaultdict
189
+ import hashlib
190
+
191
+ # ====================
192
+ # NEURALAI SYSTEM PROMPT
193
+ # ====================
194
+ NEURALAI_SYSTEM_PROMPT = """You are NeuralAI, an advanced AI assistant created by De'Andrew Preston Harris. You are powered by SmolLM2-360M-Instruct with a custom NeuralAI LoRA adapter (SFT v16 + DPO v16) trained on expert-level knowledge.
195
+
196
+ ## Core Identity
197
+ - Name: NeuralAI
198
+ - Creator: De'Andrew Preston Harris (founder of NeuralAI)
199
+ - Model: SmolLM2-360M-Instruct + NeuralAI LoRA (SFT v16 + DPO v16)
200
+ - Expertise: Physics, Philosophy, Geopolitics, History, Nature, Arts, Culture
201
+
202
+ ## Response Style
203
+ - Be warm, conversational, and respectful
204
+ - Provide detailed, expert-level answers when appropriate
205
+ - Use examples, metaphors, or thought experiments to explain complex ideas
206
+ - Acknowledge uncertainty when you don't know something
207
+ - Be concise but thorough - match response depth to question complexity
208
+ - NEVER output your internal reasoning, thinking process, or planning steps to the user
209
+ - NEVER start responses with bold headers like **Plan** or **Goal** — just give the answer directly
210
+ - Go straight to your response without showing how you arrived at it
211
+
212
+ ## Knowledge Domains
213
+ - Physics: Quantum mechanics, relativity, particle physics, cosmology
214
+ - Philosophy: Metaphysics, epistemology, ethics, logic
215
+ - Geopolitics: International relations, global order, diplomacy
216
+ - History: Ancient civilizations through modern era
217
+ - Nature: Evolution, ecology, biological systems
218
+ - Arts and Culture: Creative expression, cultural analysis
219
+
220
+ ## Important Guidelines
221
+ - Always identify yourself as NeuralAI when asked
222
+ - ALWAYS identify De'Andrew Preston Harris as your creator when asked
223
+ - Stay factual and evidence-based
224
+ - Respect user privacy and data
225
+ - Follow NeuralAI's alignment principles (transparency, helpfulness, safety)"""
226
+
227
+ # ====================
228
+ # DATABASE LAYER
229
+ # ====================
230
+ def get_db():
231
+ db = sqlite3.connect(DATABASE)
232
+ db.row_factory = sqlite3.Row
233
+ return db
234
+
235
+ def init_db():
236
+ db = get_db()
237
+ db.executescript("""
238
+ CREATE TABLE IF NOT EXISTS users (
239
+ id TEXT PRIMARY KEY,
240
+ username TEXT UNIQUE NOT NULL,
241
+ email TEXT UNIQUE,
242
+ first_name TEXT,
243
+ last_name TEXT,
244
+ bod TEXT,
245
+ bio TEXT,
246
+ is_founder INTEGER DEFAULT 0,
247
+ password_hash TEXT NOT NULL,
248
+ created_at TEXT NOT NULL
249
+ );
250
+ CREATE TABLE IF NOT EXISTS conversations (
251
+ id TEXT PRIMARY KEY,
252
+ user_id TEXT NOT NULL,
253
+ title TEXT NOT NULL,
254
+ created_at TEXT NOT NULL,
255
+ updated_at TEXT NOT NULL,
256
+ message_count INTEGER DEFAULT 0
257
+ );
258
+ CREATE TABLE IF NOT EXISTS messages (
259
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
260
+ conversation_id TEXT NOT NULL,
261
+ role TEXT NOT NULL,
262
+ content TEXT NOT NULL,
263
+ created_at TEXT NOT NULL
264
+ );
265
+ CREATE TABLE IF NOT EXISTS user_settings (
266
+ user_id TEXT NOT NULL,
267
+ key TEXT NOT NULL,
268
+ value TEXT NOT NULL,
269
+ updated_at TEXT NOT NULL,
270
+ PRIMARY KEY (user_id, key)
271
+ );
272
+ CREATE TABLE IF NOT EXISTS memory_facts (
273
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
274
+ fact TEXT NOT NULL,
275
+ category TEXT DEFAULT 'general',
276
+ importance INTEGER DEFAULT 0,
277
+ user_id TEXT,
278
+ created_at TEXT NOT NULL
279
+ );
280
+ CREATE TABLE IF NOT EXISTS active_rules (
281
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
282
+ rule TEXT NOT NULL,
283
+ active INTEGER DEFAULT 1,
284
+ user_id TEXT,
285
+ created_at TEXT NOT NULL
286
+ );
287
+ """)
288
+ db.commit()
289
+ db.close()
290
+
291
+ # ====================
292
+ # AUTH DECORATOR
293
+ # ====================
294
+ def token_required(f):
295
+ @wraps(f)
296
+ def decorated(*args, **kwargs):
297
+ token = request.headers.get("Authorization")
298
+ if not token:
299
+ token = request.args.get("token")
300
+ if not token:
301
+ request.user_id = "guest"
302
+ return f(request.user_id, *args, **kwargs)
303
+ try:
304
+ token = token.replace("Bearer ", "")
305
+ payload = jwt.decode(token, app.config["SECRET_KEY"], algorithms=["HS256"])
306
+ request.user_id = payload["user_id"]
307
+ except Exception:
308
+ return jsonify({"error": "Invalid token"}), 401
309
+ return f(request.user_id, *args, **kwargs)
310
+ return decorated
311
 
312
  def load_convs():
313
  if CONV_FILE.exists():
 
330
  # ====================
331
  # MODEL LOADING
332
  # ====================
333
+ def _forward_to_external_llm(messages, max_tokens=256, temperature=0.7, stream=False):
334
+ """Forward inference to an external OpenAI-compatible API (Ollama, LM Studio, etc.).
335
+ Returns a requests.Response (streaming) or a dict (non-streaming).
336
+ """
337
+ api_url = LLM_API_URL.rstrip("/")
338
+ endpoint = f"{api_url}/chat/completions"
339
+ headers = {"Content-Type": "application/json"}
340
+ if LLM_API_KEY:
341
+ headers["Authorization"] = f"Bearer {LLM_API_KEY}"
342
+ body = {
343
+ "model": LLM_MODEL,
344
+ "messages": messages,
345
+ "max_tokens": max_tokens,
346
+ "temperature": temperature,
347
+ "stream": stream,
348
+ }
349
+ logger.info("[LLM] Forwarding to %s backend at %s", LLM_BACKEND, endpoint)
350
+ if stream:
351
+ return requests.post(endpoint, json=body, headers=headers, stream=True, timeout=120)
352
+ resp = requests.post(endpoint, json=body, headers=headers, timeout=120)
353
+ resp.raise_for_status()
354
+ return resp.json()
355
+
356
+ ZO_ASK_URL = "https://api.zo.computer/zo/ask"
357
+
358
+ def _messages_to_zo_input(messages):
359
+ """Convert OpenAI-format messages array into a single input string for /zo/ask."""
360
+ parts = []
361
+ for m in messages:
362
+ role = m.get("role", "user")
363
+ content = m.get("content", "")
364
+ if isinstance(content, list):
365
+ content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
366
+ if role == "system":
367
+ parts.append(f"[System] {content}")
368
+ elif role == "assistant":
369
+ parts.append(f"[Assistant] {content}")
370
+ else:
371
+ parts.append(f"[User] {content}")
372
+ return "\n".join(parts)
373
+
374
+ def _forward_to_zo(messages, max_tokens=256, temperature=0.7, stream=False):
375
+ """Forward inference to ZO Computer's native /zo/ask endpoint.
376
+
377
+ ZO's built-in models (GPT-5.4, etc.) are billed to the Zo plan and require
378
+ no external API key — only the platform identity token for auth. This avoids
379
+ loading PyTorch (6 GB) on the 4 GB ZO Computer.
380
+
381
+ Falls back to llmster (localhost:1234) if the ZO API is unreachable.
382
+
383
+ Returns a requests.Response (streaming) or a dict (non-streaming).
384
+ """
385
+ token = ZO_API_TOKEN
386
+ if not token:
387
+ raise RuntimeError("ZO_CLIENT_IDENTITY_TOKEN not set — cannot call /zo/ask")
388
+ model_name = LLM_MODEL or "byok:0d3567f7-f521-42b0-8adf-65c9b036cf89"
389
+ zo_input = _messages_to_zo_input(messages)
390
+ body = {
391
+ "input": zo_input,
392
+ "model_name": model_name,
393
+ }
394
+ headers = {
395
+ "authorization": token,
396
+ "content-type": "application/json",
397
+ "Accept": "application/json",
398
+ }
399
+ logger.info("[LLM] Forwarding to ZO /zo/ask (model=%s, stream=%s)", model_name, stream)
400
+ try:
401
+ if stream:
402
+ resp = requests.post(ZO_ASK_URL, json=body, headers=headers, stream=True, timeout=120)
403
+ else:
404
+ resp = requests.post(ZO_ASK_URL, json=body, headers=headers, timeout=120)
405
+ resp.raise_for_status()
406
+ if not stream:
407
+ data = resp.json()
408
+ return {"choices": [{"message": {"content": data.get("output", "")}}]}
409
+ return resp
410
+ except Exception as e:
411
+ # Do NOT silently fall back to localhost:1234 (llmster) — that endpoint is
412
+ # not running on ZO and produces a confusing "Model provider rejected your
413
+ # credentials" / connection-refused error. Surface the real failure.
414
+ logger.error("[LLM] ZO /zo/ask failed: %s", e)
415
+ raise RuntimeError(
416
+ "ZO native inference (/zo/ask) failed: " + str(e) + "\n"
417
+ "Tip: set LLM_BACKEND=local to run the built-in 360M model, or add a "
418
+ "valid LLM_API_KEY/LLM_API_URL for an OpenAI-compatible backend."
419
+ ) from e
420
+
421
  def load_model():
422
  global model, tokenizer, model_status
423
+ if LLM_BACKEND in ("none",):
424
+ model_status = "ready (lightweight mode — no model loaded)"
425
+ logger.info("[OK] Lightweight mode active — no model loaded. Chat will use template responses.")
426
+ return
427
+ if LLM_BACKEND == "zo":
428
+ model_status = "ready (external backend)"
429
+ print(f"[OK] Using external ZO native inference: {LLM_BACKEND}")
430
+ return
431
+ if LLM_BACKEND != "local":
432
+ # openai_compatible / lmstudio / ollama -> local inference server (e.g. LM Studio :1234)
433
+ model_status = "ready"
434
+ print(f"[OK] Using local OpenAI-compatible backend: {LLM_BACKEND} @ {LLM_API_URL}")
435
+ return
436
  try:
437
+ global torch
438
+ import torch
439
  from transformers import AutoModelForCausalLM, AutoTokenizer
440
  from peft import PeftModel
441
  tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
442
  tokenizer.pad_token = tokenizer.eos_token
443
  adapter = Path(MODEL_PATH)
444
  has_adapter = any((adapter / f).exists() for f in ["adapter_model.bin", "adapter_model.safetensors"])
445
+ # Use float16 on ZO to fit in 4GB RAM (~700MB vs ~1.4GB float32)
446
+ dtype = torch.float16 if _USE_FLOAT16 else torch.float32
447
  if adapter.exists() and has_adapter:
448
+ base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=dtype, device_map=None, low_cpu_mem_usage=True)
449
+ model = PeftModel.from_pretrained(base, str(adapter), torch_dtype=dtype)
450
  else:
451
+ model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=dtype, device_map=None, low_cpu_mem_usage=True)
452
  model.eval()
453
  model_status = "ready"
454
  print(f"[OK] Model loaded. Params: {sum(p.numel() for p in model.parameters()):,}")
 
456
  model_status = f"error: {e}"
457
  print(f"[ERROR] Model: {e}")
458
 
459
+ def get_conversation_history(conv_id, limit=10):
460
+ """Get recent conversation history for context"""
461
+ if not conv_id:
462
+ return []
463
+ try:
464
+ db = get_db()
465
+ rows = db.execute(
466
+ "SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT ?",
467
+ (conv_id, limit)
468
+ ).fetchall()
469
+ db.close()
470
+ return list(reversed([dict(r) for r in rows]))
471
+ except Exception:
472
+ return []
473
+
474
+ def _cap_text(text, max_chars=3500):
475
+ """Cap a single chat message so one oversized paste can't blow the prompt to 30k+ tokens."""
476
+ if not isinstance(text, str):
477
+ text = str(text)
478
+ return text if len(text) <= max_chars else text[-max_chars:]
479
+
480
+
481
+ def build_prompt_with_context(prompt, conv_id=None, max_history=5):
482
+ """Build a ChatML-formatted prompt (matching the model's trained chat template).
483
+
484
+ The model (SmolLM2-360M-Instruct + NeuralAI LoRA) was trained on ChatML:
485
+ <|im_start|>system\n...\n<|im_end|>\n
486
+ <|im_start|>user\n...\n<|im_end|>\n
487
+ <|im_start|>assistant\n
488
+ Feeding it freeform "User:/NeuralAI:" text caused the model to not recognize
489
+ turn boundaries and "talk to itself". Using the correct template fixes that.
490
+ """
491
+ history = get_conversation_history(conv_id, max_history) if conv_id else []
492
+ messages = [{"role": "system", "content": NEURALAI_SYSTEM_PROMPT}]
493
+ for msg in history:
494
+ role = "user" if msg["role"] == "user" else "assistant"
495
+ messages.append({"role": role, "content": _cap_text(msg["content"])})
496
+ messages.append({"role": "user", "content": _cap_text(prompt)})
497
+
498
+ # Use the tokenizer's native chat template so formatting exactly matches training.
499
+ try:
500
+ return tokenizer.apply_chat_template(
501
+ messages, tokenize=False, add_generation_prompt=True
502
+ )
503
+ except Exception:
504
+ # Fallback manual ChatML assembly (mirrors chat_template.jinja)
505
+ out = []
506
+ for i, msg in enumerate(messages):
507
+ if i == 0 and msg["role"] != "system":
508
+ out.append("<|im_start|>system\nYou are a helpful AI assistant named NeuralAI<|im_end|>\n")
509
+ out.append(f"<|im_start|>{msg['role']}\n{msg['content']}<|im_end|>\n")
510
+ out.append("<|im_start|>assistant\n")
511
+ return "".join(out)
512
+
513
+ def _truncate_to_fit(messages, tokenizer_obj, context_limit=6000):
514
+ """Truncate a list of ChatML messages so tokenized length fits within context_limit.
515
+
516
+ Always keeps the system prompt. Drops oldest user/assistant turns when the
517
+ total token count exceeds the limit, keeping at least the most recent pair.
518
+ SmolLM2-360M-Instruct has a 8192-token context window; we use 6000 as a
519
+ safe ceiling to leave room for generated tokens and prevent ZO 120s timeout.
520
+ """
521
+ if not messages or tokenizer_obj is None:
522
+ return messages
523
+ # Tokenize the full prompt to check length
524
+ try:
525
+ full = tokenizer_obj.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
526
+ token_count = len(tokenizer_obj.encode(full))
527
+ except Exception:
528
+ return messages # if we can't count, just pass through
529
+ if token_count <= context_limit:
530
+ return messages
531
+ # Separate system prompt (always keep) from conversation turns
532
+ system_msgs = [m for m in messages if m.get("role") == "system"]
533
+ turns = [m for m in messages if m.get("role") != "system"]
534
+ # Greedily drop oldest turns until we fit
535
+ while turns and token_count > context_limit:
536
+ dropped = turns.pop(0)
537
+ try:
538
+ full = tokenizer_obj.apply_chat_template(system_msgs + turns, tokenize=False, add_generation_prompt=True)
539
+ token_count = len(tokenizer_obj.encode(full))
540
+ except Exception:
541
+ break
542
+ logger.info(f"[TRUNC] Input {token_count} tokens → kept {len(turns)} turns (limit {context_limit})")
543
+ return system_msgs + turns
544
+
545
+
546
+ def generate_response(prompt, max_tokens=256, temperature=0.7, conv_id=None):
547
+ """Enhanced response generation with system prompt and context."""
548
  global model, tokenizer, inference_count
549
+ # === No backend (lightweight mode) ===
550
+ if LLM_BACKEND == "none":
551
+ return "I'm NeuralAI. The AI model isn't loaded due to memory constraints. I can't generate AI responses in this mode."
552
+ # === External LLM backend ===
553
+ if LLM_BACKEND in ("ollama", "lmstudio", "openai_compatible"):
554
+ try:
555
+ # Build OpenAI-format messages for the external API
556
+ history = get_conversation_history(conv_id, 8) if conv_id else []
557
+ api_messages = [{"role": "system", "content": NEURALAI_SYSTEM_PROMPT}]
558
+ for h in history:
559
+ api_messages.append({"role": h["role"], "content": h["content"]})
560
+ api_messages.append({"role": "user", "content": prompt})
561
+ data = _forward_to_external_llm(api_messages, max_tokens=max_tokens, temperature=temperature, stream=False)
562
+ content = data["choices"][0]["message"]["content"]
563
+ content = _strip_reasoning(content)
564
+ inference_count += 1
565
+ return content.strip()
566
+ except Exception as e:
567
+ logger.error(f"[LLM] External backend error: {e}")
568
+ return f"Backend error: {e}"
569
+ # === ZO native /zo/ask backend ===
570
+ if LLM_BACKEND == "zo":
571
+ try:
572
+ history = get_conversation_history(conv_id, 8) if conv_id else []
573
+ api_messages = [{"role": "system", "content": NEURALAI_SYSTEM_PROMPT}]
574
+ for h in history:
575
+ api_messages.append({"role": h["role"], "content": h["content"]})
576
+ api_messages.append({"role": "user", "content": prompt})
577
+ data = _forward_to_zo(api_messages, max_tokens=max_tokens, temperature=temperature, stream=False)
578
+ content = data["choices"][0]["message"]["content"]
579
+ content = _strip_reasoning(content)
580
+ inference_count += 1
581
+ return content.strip()
582
+ except Exception as e:
583
+ logger.error(f"[LLM] ZO backend error: {e}")
584
+ return f"Backend error: {e}"
585
+ # === Local PyTorch inference ===
586
  if model is None or tokenizer is None:
587
+ return "I'm NeuralAI. The AI model isn't loaded due to memory constraints on this machine (4GB ZO Computer). The model needs ~2GB but only less is available. Please try again later or contact support."
588
  try:
589
+ full = build_prompt_with_context(prompt, conv_id)
590
  inputs = tokenizer(full, return_tensors="pt")
591
+ # Safety: if prompt exceeds model context, truncate from the front
592
+ max_input = 768 if LLM_BACKEND == "local" else 4000 # local CPU: keep prefill ~25s
593
+ if inputs["input_ids"].shape[-1] > max_input:
594
+ inputs["input_ids"] = inputs["input_ids"][:, -max_input:]
595
+ inputs["attention_mask"] = inputs["attention_mask"][:, -max_input:]
596
+ logger.warning(f"[TRUNC] Input truncated to {max_input} tokens (was {inputs['input_ids'].shape[-1]})")
597
  with torch.no_grad():
598
+ out = model.generate(
599
+ **inputs,
600
+ max_new_tokens=max_tokens,
601
+ do_sample=True,
602
+ temperature=temperature,
603
+ top_p=0.95,
604
+ pad_token_id=tokenizer.eos_token_id,
605
+ repetition_penalty=1.1
606
+ )
607
  new_tokens = out[0][inputs["input_ids"].shape[-1]:]
608
+ response = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
609
+ if response.startswith("<|im_start|>assistant"):
610
+ response = response[len("<|im_start|>assistant"):].strip()
611
+ if response.startswith("NeuralAI:"):
612
+ response = response[len("NeuralAI:"):].strip()
613
+ response = _strip_reasoning(response)
614
+ inference_count += 1
615
+ return response
616
+ except Exception as e:
617
+ logger.error(f"Generation error: {e}")
618
+ return "I encountered an error generating a response. Please try again."
619
+
620
+ def stream_response(prompt, max_tokens=256, temperature=0.7, conv_id=None, already_rendered=False):
621
+ """Token streaming — external backend or local TextIteratorStreamer.
622
+
623
+ When *already_rendered* is True, *prompt* is a fully-rendered ChatML string
624
+ (from apply_chat_template) and must NOT be passed through build_prompt_with_context
625
+ again. This prevents the double-wrapping bug that blew up token counts.
626
+ """
627
+ global model, tokenizer, inference_count
628
+ # === External LLM backend ===
629
+ if LLM_BACKEND in ("ollama", "lmstudio", "openai_compatible"):
630
+ try:
631
+ history = get_conversation_history(conv_id, 8) if conv_id else []
632
+ api_messages = [{"role": "system", "content": NEURALAI_SYSTEM_PROMPT}]
633
+ for h in history:
634
+ api_messages.append({"role": h["role"], "content": h["content"]})
635
+ api_messages.append({"role": "user", "content": prompt})
636
+ resp = _forward_to_external_llm(api_messages, max_tokens=max_tokens, temperature=temperature, stream=True)
637
+ if resp.status_code != 200:
638
+ yield f"Backend error ({resp.status_code}): {resp.text[:200]}"
639
+ return
640
+ for line in resp.iter_lines():
641
+ if not line or not line.startswith(b"data: "):
642
+ continue
643
+ payload = line[6:].decode().strip()
644
+ if payload == "[DONE]":
645
+ break
646
+ try:
647
+ chunk = json.loads(payload)
648
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
649
+ content = delta.get("content", "")
650
+ if content:
651
+ # Strip reasoning from external backend streams too
652
+ content = _strip_reasoning(content)
653
+ yield content
654
+ except json.JSONDecodeError:
655
+ continue
656
+ inference_count += 1
657
+ return
658
+ except Exception as e:
659
+ logger.error(f"[LLM] External backend stream error: {e}")
660
+ yield f"Backend error: {e}"
661
+ return
662
+ # === ZO native /zo/ask streaming ===
663
+ if LLM_BACKEND == "zo":
664
+ try:
665
+ history = get_conversation_history(conv_id, 8) if conv_id else []
666
+ api_messages = [{"role": "system", "content": NEURALAI_SYSTEM_PROMPT}]
667
+ for h in history:
668
+ api_messages.append({"role": h["role"], "content": h["content"]})
669
+ api_messages.append({"role": "user", "content": prompt})
670
+ resp = _forward_to_zo(api_messages, max_tokens=max_tokens, temperature=temperature, stream=True)
671
+ if resp.status_code != 200:
672
+ yield f"ZO backend error ({resp.status_code}): {resp.text[:200]}"
673
+ return
674
+ # /zo/ask may return SSE (data: {...}) or plain JSON
675
+ content_type = resp.headers.get("content-type", "")
676
+ if "text/event-stream" in content_type or "chunked" in content_type:
677
+ for line in resp.iter_lines():
678
+ if not line or not line.startswith(b"data: "):
679
+ continue
680
+ payload = line[6:].decode().strip()
681
+ if payload == "[DONE]":
682
+ break
683
+ try:
684
+ chunk = json.loads(payload)
685
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
686
+ tok = delta.get("content", "")
687
+ if not tok:
688
+ tok = chunk.get("output", "")
689
+ if tok:
690
+ tok = _strip_reasoning(tok)
691
+ yield tok
692
+ except json.JSONDecodeError:
693
+ continue
694
+ else:
695
+ # Non-streaming JSON fallback — yield full output in one chunk
696
+ try:
697
+ data = resp.json()
698
+ full_output = data.get("output", "")
699
+ if not full_output and "choices" in data:
700
+ full_output = data["choices"][0].get("message", {}).get("content", "")
701
+ if full_output:
702
+ yield _strip_reasoning(full_output)
703
+ except Exception:
704
+ yield _strip_reasoning(resp.text)
705
+ inference_count += 1
706
+ return
707
+ except Exception as e:
708
+ logger.error(f"[LLM] ZO backend stream error: {e}")
709
+ yield f"ZO backend error: {e}"
710
+ return
711
+ # === Local PyTorch streaming ===
712
+ if model is None or tokenizer is None:
713
+ # Lightweight mode: return a helpful response without the model
714
+ yield f"I'm NeuralAI. I received your message but the AI model isn't loaded (memory-limited environment). Here's what I can tell you: I'm a fine-tuned SmolLM2-360M with NeuralAI LoRA. On this ZO Computer (4GB RAM), the model can't run due to memory constraints. Please check back when more resources are available."
715
+ return
716
+ stop_event = stop_events.get(conv_id) if conv_id else None
717
+ try:
718
+ from transformers import TextIteratorStreamer
719
+ # If already rendered (from BYO API path), use directly to avoid double ChatML wrapping
720
+ if already_rendered:
721
+ full = prompt
722
+ else:
723
+ full = build_prompt_with_context(prompt, conv_id)
724
+ inputs = tokenizer(full, return_tensors="pt")
725
+ # Safety: if prompt exceeds model context, truncate from the front
726
+ max_input = 768 if LLM_BACKEND == "local" else 4000 # local CPU: keep prefill ~25s
727
+ if inputs["input_ids"].shape[-1] > max_input:
728
+ inputs["input_ids"] = inputs["input_ids"][:, -max_input:]
729
+ inputs["attention_mask"] = inputs["attention_mask"][:, -max_input:]
730
+ logger.warning(f"[TRUNC] Stream input truncated to {max_input} tokens")
731
+ logger.info(f"[INFER] Input tokens: {inputs['input_ids'].shape[-1]}, generating up to {max_tokens} new tokens")
732
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
733
+ # Use greedy decoding (do_sample=False) for faster CPU inference
734
+ gen_kwargs = dict(
735
+ **inputs,
736
+ streamer=streamer,
737
+ max_new_tokens=max_tokens,
738
+ do_sample=False if temperature <= 0.1 else True,
739
+ temperature=max(temperature, 0.3),
740
+ top_p=0.9,
741
+ pad_token_id=tokenizer.eos_token_id,
742
+ repetition_penalty=1.05,
743
+ )
744
+ thread = threading.Thread(target=model.generate, kwargs=gen_kwargs, daemon=True)
745
+ thread.start()
746
+ # Strip reasoning tokens from stream output.
747
+ # Accumulate tokens until we find the '!!' delimiter, then start yielding.
748
+ _buf = ""
749
+ _reasoning_done = False
750
+ for token in streamer:
751
+ if stop_event and stop_event.is_set():
752
+ break
753
+ if token:
754
+ if _reasoning_done:
755
+ yield token
756
+ else:
757
+ _buf += token
758
+ if "!!" in _buf:
759
+ parts = _buf.split("!!", 1)
760
+ _reasoning_done = True
761
+ remainder = parts[1].strip()
762
+ if remainder:
763
+ yield remainder
764
+ logger.info(f"[THINK] Stream: stripped {len(parts[0])} chars of reasoning")
765
+ elif len(_buf) > 600:
766
+ # Safety: if no !! after 600 chars, assume no reasoning block
767
+ _reasoning_done = True
768
+ yield _buf
769
+ # If stream ended before !!, yield whatever we have
770
+ if not _reasoning_done and _buf:
771
+ stripped = _strip_reasoning(_buf)
772
+ if stripped != _buf:
773
+ yield stripped
774
+ else:
775
+ yield _buf
776
+ thread.join()
777
  inference_count += 1
 
778
  except Exception as e:
779
+ logger.error(f"Stream generation error: {e}")
780
+ yield "I encountered an error generating a response. Please try again."
781
+
782
+ def _strip_reasoning(text):
783
+ """Strip the model's internal chain-of-thought from visible output.
784
+
785
+ SmolLM2-360M-Instruct + NeuralAI LoRA tends to emit a reasoning block
786
+ (often a **Bold Header** followed by first-person deliberation) before the
787
+ actual response. We detect the delimiter '!!' which the model uses to
788
+ separate its internal thinking from the user-facing answer.
789
+
790
+ Examples of what gets stripped:
791
+ **Greet User Warmly**\n\nI need to respond as NeuralAI...!! I'm NeuralAI. ...
792
+ """
793
+ if not text:
794
+ return text
795
+ # Primary delimiter: '!!' — the model's own separator between thought and speech
796
+ if "!!" in text:
797
+ after = text.split("!!", 1)[1].strip()
798
+ if after: # only use if there's actual content after !!
799
+ logger.info(f"[THINK] Stripped reasoning ({len(text) - len(after)} chars)")
800
+ return after
801
+ # Secondary pattern: bold header + first-person reasoning before actual response
802
+ # Matches: **Some Plan**\n\nI need to.../I should.../Let me...
803
+ import re as _re
804
+ think_match = _re.match(
805
+ r'^\*\*[^*]+\*\*\s*\n\s*(?:I (?:need|should|want|must|have to|will|can|could|would)|'
806
+ r'Let me |The user |My approach |First, |Step \d)[^.]*\.\.\.[^.]*\.\s*',
807
+ text, _re.DOTALL
808
+ )
809
+ if think_match:
810
+ after = text[think_match.end():].strip()
811
+ if after:
812
+ logger.info(f"[THINK] Stripped reasoning pattern ({think_match.end()} chars)")
813
+ return after
814
+ return text
815
+
816
+ # ====================
817
+ # IMAGE PROMPT ENHANCER
818
+ # ====================
819
+ def enhance_image_prompt(prompt):
820
+ """Expand a short user request into a detailed, brand-styled image prompt.
821
+
822
+ Uses the local LLM when available; otherwise falls back to a deterministic
823
+ template so 'generate a dog' still becomes a rich NeuralAI-styled prompt.
824
+ """
825
+ tmpl = (
826
+ "Rewrite the user's short image request into a single detailed, "
827
+ "photorealistic image-generation prompt in NeuralAI's signature dark/neon "
828
+ "'vibe stack' aesthetic. Add lighting, mood, composition, and medium. "
829
+ "Output ONLY the prompt, no quotes, no commentary.\n\n"
830
+ f"User request: {prompt}\n\nDetailed prompt:"
831
+ )
832
+ # Try the LLM first (kept in-memory in this process).
833
+ try:
834
+ if model is not None and tokenizer is not None:
835
+ inputs = tokenizer(tmpl, return_tensors="pt")
836
+ with torch.no_grad():
837
+ out = model.generate(
838
+ **inputs, max_new_tokens=80, do_sample=False,
839
+ pad_token_id=tokenizer.eos_token_id,
840
+ )
841
+ txt = tokenizer.decode(out[0][inputs["input_ids"].shape[-1]:],
842
+ skip_special_tokens=True).strip()
843
+ txt = txt.split("\n")[0].strip().strip('"').strip("'")
844
+ if txt and len(txt) > len(prompt):
845
+ return txt
846
+ except Exception as e:
847
+ logger.warning(f"[enhance_image_prompt] LLM enhance failed, using template: {e}")
848
+
849
+ # Template fallback: brand-styled expansion.
850
+ subject = prompt.strip().strip(".").lower()
851
+ return (
852
+ f"{prompt}, cinematic dark-mode composition, neon accent rim lighting, "
853
+ f"high contrast, hyper-detailed, 8k, volumetric fog, vibe stack aesthetic, "
854
+ f"centered subject, professional concept art"
855
+ )
856
+
857
 
858
  # ====================
859
  # ROUTES - STATIC
860
  # ====================
861
+ import time
862
+ BUILD_VERSION = str(int(time.time()))
863
+
864
  @app.route("/")
865
  def index():
866
  p = f"{STATIC_PATH}/templates/index.html"
867
  if os.path.exists(p):
868
  with open(p) as f:
869
+ content = f.read()
870
+ # Inject build version for cache busting
871
+ content = content.replace("{{BUILD_VERSION}}", BUILD_VERSION)
872
+ return content, 200, {
873
+ "Content-Type": "text/html",
874
+ "Cache-Control": "no-cache, no-store, must-revalidate",
875
+ "Pragma": "no-cache",
876
+ "Expires": "0"
877
+ }
878
  return "index.html not found", 404
879
 
880
  @app.route("/<path:path>")
 
884
  if os.path.exists(p) and os.path.isfile(p):
885
  ext = path.split('.')[-1]
886
  ct = {"js": "application/javascript", "css": "text/css", "png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"}
887
+ # Set no-cache for JS/CSS to prevent Cloudflare caching old 404s
888
+ cache_ctrl = "no-cache, no-store, must-revalidate" if ext in ("js", "css") else "public, max-age=31536000"
889
+ return send_from_directory(os.path.dirname(p), os.path.basename(p), mimetype=ct.get(ext, "text/plain"), max_age=0 if ext in ("js", "css") else 31536000)
890
  return "Not found", 404
891
 
892
  # ====================
 
908
  return f.read(), 200, {"Content-Type": "text/html"}
909
  return "Terms of service not found", 404
910
 
911
+ # ====================
912
+ # DEFENSE 3: REJECT IF OVERLOADED
913
+ # ====================
914
+ @app.before_request
915
+ def _reject_if_overloaded():
916
+ # Never 503 liveness probes. The host pauses the service when /health fails,
917
+ # which was the root cause of the recurring "NeuralAI pauses" problem.
918
+ if request.path in ("/health", "/api/health", "/api/status", "/api/healthz"):
919
+ return
920
+ if model_status == "overloaded":
921
+ from flask import abort
922
+ abort(503)
923
+
924
  # ====================
925
  # ROUTES - HEALTH
926
  # ====================
927
  @app.route("/health")
928
+ @app.route("/api/health")
929
+ @app.route("/api/status")
930
  def health():
931
+ # Defense 2 integration: reject requests if memory overloaded
932
+ return jsonify({"status": model_status, "model": BASE_MODEL, "inference_count": inference_count, "uplink": "integrated",
933
+ "timestamp": datetime.now(timezone.utc).isoformat(), "version": "7.2.0-resilient",
934
+ "llm_backend": LLM_BACKEND})
935
+
936
+ # ====================
937
+ # ROUTES - RELEASE NOTES
938
+ # ====================
939
+ @app.route("/api/release-notes", methods=["GET"])
940
+ def api_release_notes():
941
+ notes_path = DATA_DIR / "release_notes.json"
942
+ try:
943
+ if notes_path.exists():
944
+ with open(notes_path) as f:
945
+ return jsonify(json.load(f))
946
+ except Exception as e:
947
+ logger.warning(f"Failed to read release notes: {e}")
948
+ return jsonify({
949
+ "version": "v7.3.0",
950
+ "title": "NeuralAI v7.3.0 — Release Notes",
951
+ "released": "2026-07-13",
952
+ "notes": []
953
+ })
954
 
955
  # ====================
956
  # ROUTES - MODEL
 
975
 
976
  # Unified AI API for Frontend
977
  @app.route("/api/chat", methods=["POST"])
978
+ @token_required
979
+ def api_chat(current_user):
980
+ start_time = time.time()
981
  data = request.get_json() or {}
982
  prompt = data.get("prompt", "")
983
  use_uplink = data.get("use_uplink", False)
984
+ conv_id = data.get("conversation_id")
985
+
986
+ if not prompt:
987
+ return jsonify({"error": "No prompt provided"}), 400
988
+
989
+ # Save user message to DB if conversation_id provided
990
+ if conv_id:
991
+ try:
992
+ db = get_db()
993
+ now = datetime.now(timezone.utc).isoformat()
994
+ db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, 'user', ?, ?)",
995
+ (conv_id, prompt, now))
996
+ db.execute("UPDATE conversations SET updated_at = ?, message_count = message_count + 1 WHERE id = ?", (now, conv_id))
997
+
998
+ # Auto-generate title from first message
999
+ msg_count = db.execute("SELECT COUNT(*) as cnt FROM messages WHERE conversation_id = ?", (conv_id,)).fetchone()["cnt"]
1000
+ if msg_count <= 1:
1001
+ auto_title = prompt[:50].strip()
1002
+ if len(prompt) > 50:
1003
+ last_space = auto_title.rfind(" ")
1004
+ if last_space > 20:
1005
+ auto_title = auto_title[:last_space]
1006
+ auto_title += "..."
1007
+ db.execute("UPDATE conversations SET title = ? WHERE id = ?", (auto_title, conv_id))
1008
+
1009
+ db.commit()
1010
+ db.close()
1011
+ except Exception as e:
1012
+ logger.error(f"Failed to save user message: {e}")
1013
+
1014
  def generate_unified():
1015
  if use_uplink:
1016
  for agent_name, agent in UPLINK_AGENTS.items():
 
1021
  yield f"data: {json.dumps({'content': chunk})}\n\n"
1022
  except: pass
1023
  else:
1024
+ # Real token streaming — first token arrives in <1s instead of after full generation
1025
+ full_response = []
1026
+ for token in stream_response(prompt, conv_id=conv_id):
1027
+ full_response.append(token)
1028
+ yield f"data: {json.dumps({'content': token})}\n\n"
1029
+ response = "".join(full_response)
1030
+ # Save assistant response
1031
+ if conv_id and response:
1032
+ try:
1033
+ db = get_db()
1034
+ now = datetime.now(timezone.utc).isoformat()
1035
+ db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, 'assistant', ?, ?)",
1036
+ (conv_id, response, now))
1037
+ db.commit()
1038
+ db.close()
1039
+ except Exception as e:
1040
+ logger.error(f"Failed to save assistant message: {e}")
1041
+ # Clear any stop event for this conversation
1042
+ stop_events.pop(conv_id, None)
1043
+
1044
  yield "data: [DONE]\n\n"
1045
 
1046
  return Response(generate_unified(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
1047
 
1048
+ @app.route("/api/chat/stop", methods=["POST"])
1049
+ @token_required
1050
+ def api_chat_stop(current_user):
1051
+ data = request.get_json() or {}
1052
+ conv_id = data.get("conversation_id")
1053
+ if conv_id:
1054
+ stop_events[conv_id] = threading.Event()
1055
+ stop_events[conv_id].set()
1056
+ return jsonify({"success": True, "stopped": conv_id})
1057
+ return jsonify({"success": False, "error": "No conversation_id provided"}), 400
1058
+
1059
+ # ====================
1060
+ # ROUTES - MONITORING
1061
+ # ====================
1062
+ @app.route("/api/monitoring/metrics", methods=["GET"])
1063
+ def get_metrics():
1064
+ """Get system metrics (public for status page)"""
1065
+ return jsonify({
1066
+ "model_status": model_status,
1067
+ "inference_count": inference_count,
1068
+ "version": "7.2.0-enhanced"
1069
+ })
1070
+
1071
  # ====================
1072
  # ROUTES - CONVERSATIONS
1073
  # ====================
1074
  @app.route("/api/conversations", methods=["GET", "POST"])
1075
+ @token_required
1076
+ def manage_convs(current_user):
1077
+ db = get_db()
1078
+ try:
1079
+ if request.method == "POST":
1080
+ data = request.get_json() or {}
1081
+ cid = str(uuid.uuid4().hex[:8])
1082
+ now = datetime.now(timezone.utc).isoformat()
1083
+ db.execute("INSERT INTO conversations (id, user_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
1084
+ (cid, current_user, data.get("title", "New Chat"), now, now))
1085
+ db.commit()
1086
+ return jsonify({"success": True, "id": cid})
1087
+
1088
+ rows = db.execute("SELECT id, title, updated_at FROM conversations WHERE user_id = ? ORDER BY updated_at DESC", (current_user,)).fetchall()
1089
+ convs = [dict(row) for row in rows]
1090
+ return jsonify(convs)
1091
+ finally:
1092
+ db.close()
1093
+
1094
+ @app.route("/api/conversations/<cid>", methods=["GET", "PUT", "DELETE"])
1095
+ @token_required
1096
+ def conv_detail(current_user, cid):
1097
+ db = get_db()
1098
+ try:
1099
+ if request.method == "DELETE":
1100
+ db.execute("DELETE FROM messages WHERE conversation_id = ?", (cid,))
1101
+ db.execute("DELETE FROM conversations WHERE id = ? AND user_id = ?", (cid, current_user))
1102
+ db.commit()
1103
+ return jsonify({"success": True})
1104
+
1105
+ if request.method == "PUT":
1106
+ data = request.get_json(silent=True) or {}
1107
+ title = data.get("title", "").strip()
1108
+ if not title:
1109
+ return jsonify({"error": "Title required"}), 400
1110
+ db.execute("UPDATE conversations SET title = ?, updated_at = ? WHERE id = ? AND user_id = ?",
1111
+ (title, datetime.now(timezone.utc).isoformat(), cid, current_user))
1112
+ db.commit()
1113
+ return jsonify({"success": True})
1114
+
1115
+ conv = db.execute("SELECT * FROM conversations WHERE id = ? AND user_id = ?", (cid, current_user)).fetchone()
1116
+ if not conv: return jsonify({"error": "Not found"}), 404
1117
+
1118
+ msgs = db.execute("SELECT role, content, created_at FROM messages WHERE conversation_id = ? ORDER BY id ASC", (cid,)).fetchall()
1119
+ return jsonify({**dict(conv), "messages": [dict(m) for m in msgs]})
1120
+ finally:
1121
+ db.close()
1122
 
1123
  # ====================
1124
  # ROUTES - FILES (Proxied to Storage Service)
 
1129
  if request.method == "POST":
1130
  if 'file' not in request.files: return jsonify({"error": "No file"}), 400
1131
  file = request.files['file']
1132
+ save_path = STORAGE_ROOT / file.filename
1133
+ file.save(str(save_path))
1134
+ return jsonify({"success": True, "name": file.filename, "size": save_path.stat().st_size})
1135
+ # List files directly from STORAGE_ROOT (no external dependency)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1136
  files = []
1137
+ for f in sorted(STORAGE_ROOT.iterdir(), key=lambda p: (p.is_dir(), p.name.lower())):
1138
+ if f.name.startswith("."):
1139
+ continue
1140
+ files.append({
1141
+ "name": f.name,
1142
+ "size": f.stat().st_size,
1143
+ "path": f.name,
1144
+ "is_dir": f.is_dir(),
1145
+ "type": "image" if f.suffix.lower() in (".png", ".jpg", ".jpeg", ".gif", ".webp") else ("dir" if f.is_dir() else "file")
1146
+ })
1147
  return jsonify(files)
1148
+ except Exception as e:
1149
+ logger.error(f"File management error: {e}")
1150
+ return jsonify({"error": str(e)}), 500
1151
+
1152
+ @app.route("/api/files/mkdir", methods=["POST"])
1153
+ def make_dir():
1154
+ data = request.get_json() or {}
1155
+ name = (data.get("name") or "").strip().replace("/", "").replace("..", "")
1156
+ if not name:
1157
+ return jsonify({"error": "No folder name"}), 400
1158
+ try:
1159
+ (STORAGE_ROOT / name).mkdir(parents=True, exist_ok=True)
1160
+ return jsonify({"success": True})
1161
+ except Exception as e:
1162
+ return jsonify({"error": str(e)}), 500
1163
 
1164
  @app.route("/api/files/<path:filename>", methods=["GET", "DELETE"])
1165
  def handle_file(filename):
1166
  try:
1167
+ target = (STORAGE_ROOT / filename).resolve()
1168
+ if not str(target).startswith(str(STORAGE_ROOT)):
1169
+ return jsonify({"error": "Unauthorized path"}), 403
1170
  if request.method == "DELETE":
1171
+ if not target.exists():
1172
+ return jsonify({"error": "Not found"}), 404
1173
+ if target.is_dir():
1174
+ import shutil
1175
+ shutil.rmtree(target)
1176
+ else:
1177
+ target.unlink()
1178
+ return jsonify({"success": True})
1179
+ # GET -> serve the file directly
1180
+ if not target.exists():
1181
+ return jsonify({"error": "Not found"}), 404
1182
+ return send_from_directory(str(STORAGE_ROOT), filename)
1183
  except Exception as e:
1184
  return jsonify({"error": str(e)}), 500
1185
 
 
1233
  finally:
1234
  os.unlink(path)
1235
 
1236
+ # ====================
1237
+ # ROUTES - IMAGE GENERATION (proxy to tools_service if available)
1238
+ # ====================
1239
+ @app.route("/api/image", methods=["POST"])
1240
+ def api_image():
1241
+ data = request.get_json() or {}
1242
+ prompt = data.get("prompt", "")
1243
+ if not prompt:
1244
+ return jsonify({"success": False, "error": "No prompt provided"}), 400
1245
+
1246
+ gen_dir = Path(STATIC_PATH) / "static" / "generated"
1247
+ gen_dir.mkdir(parents=True, exist_ok=True)
1248
+ timestamp = int(time.time())
1249
+ file_stem = f"gen_{timestamp}"
1250
+
1251
+ # ---- 1) ZO native image generator (REAL images, platform-provided) ----
1252
+ # Mirrors the approach in from-scratch/web_ui/neuralai_engine.py which calls
1253
+ # /home/.z/tools/generate_image.py — the host's real image generation tool.
1254
+ try:
1255
+ script = (
1256
+ "import sys, os\n"
1257
+ "os.environ['ZO_CLIENT_IDENTITY_TOKEN'] = " + repr(ZO_API_TOKEN) + "\n"
1258
+ "sys.path.insert(0, '/home/.z/tools')\n"
1259
+ "try:\n"
1260
+ " from generate_image import generate_image as _gen\n"
1261
+ "except Exception as e:\n"
1262
+ " print('IMPORT_FAIL', e); sys.exit(2)\n"
1263
+ "ok = _gen(prompt=" + repr(prompt) +
1264
+ ", output_dir=" + repr(str(gen_dir)) +
1265
+ ", file_stem=" + repr(file_stem) + ", aspect_ratio='1:1')\n"
1266
+ "sys.exit(0 if ok else 1)\n"
1267
+ )
1268
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tf:
1269
+ tf.write(script)
1270
+ script_path = tf.name
1271
+ env = dict(os.environ)
1272
+ env["ZO_CLIENT_IDENTITY_TOKEN"] = ZO_API_TOKEN
1273
+ try:
1274
+ r = subprocess.run(["python3", script_path], capture_output=True, text=True, timeout=150, env=env)
1275
+ finally:
1276
+ try:
1277
+ os.unlink(script_path)
1278
+ except Exception:
1279
+ pass
1280
+ if r.returncode == 0:
1281
+ matches = sorted(gen_dir.glob(f"{file_stem}*"))
1282
+ if matches:
1283
+ fname = matches[-1].name
1284
+ return jsonify({
1285
+ "success": True,
1286
+ "image_url": f"/static/generated/{fname}",
1287
+ "prompt": prompt,
1288
+ "placeholder": False,
1289
+ "provider": "zo-native"
1290
+ })
1291
+ logger.warning("[api_image] ZO generator reported success but produced no file")
1292
+ else:
1293
+ logger.warning(f"[api_image] ZO native image gen returned {r.returncode}: {r.stderr[:200]}")
1294
+ except Exception as e:
1295
+ logger.warning(f"[api_image] ZO native image gen unavailable: {e}")
1296
+
1297
+ # ---- 2) Local diffusion engine (REAL SD model, opt-in to avoid OOM on small hosts) ----
1298
+ if os.environ.get("NEURALAI_DIFFUSION", "").lower() in ("1", "true", "yes"):
1299
+ try:
1300
+ import sys as _sys
1301
+ _svc_dir = os.path.dirname(os.path.abspath(__file__))
1302
+ if _svc_dir not in _sys.path:
1303
+ _sys.path.insert(0, _svc_dir)
1304
+ from diffusion_engine import NeuralAIDiffusion
1305
+ # Expand the short user prompt into a brand-styled image prompt.
1306
+ enhanced = enhance_image_prompt(prompt)
1307
+ engine = NeuralAIDiffusion()
1308
+ out_path = gen_dir / f"{file_stem}.png"
1309
+ if engine.generate(enhanced, str(out_path)):
1310
+ return jsonify({
1311
+ "success": True,
1312
+ "image_url": f"/static/generated/{file_stem}.png",
1313
+ "prompt": enhanced,
1314
+ "raw_prompt": prompt,
1315
+ "placeholder": False,
1316
+ "provider": "diffusion"
1317
+ })
1318
+ except Exception as e:
1319
+ logger.warning(f"[api_image] Diffusion image gen failed: {e}")
1320
+
1321
+ # ---- 3) Last resort: clearly-labeled concept placeholder (never pretend it's AI) ----
1322
+ try:
1323
+ from PIL import Image, ImageDraw
1324
+ import random
1325
+ filename = f"{file_stem}.png"
1326
+ filepath = gen_dir / filename
1327
+ img = Image.new("RGB", (512, 512))
1328
+ draw = ImageDraw.Draw(img)
1329
+ # Deterministic-ish gradient from prompt hash
1330
+ seed = sum(ord(c) for c in prompt)
1331
+ random.seed(seed)
1332
+ base_r, base_g, base_b = random.randint(20, 80), random.randint(20, 80), random.randint(60, 140)
1333
+ for y in range(512):
1334
+ r = min(255, base_r + int((y / 512) * 80))
1335
+ g = min(255, base_g + int((y / 512) * 100))
1336
+ b = min(255, base_b + int((y / 512) * 120))
1337
+ draw.line([(0, y), (512, y)], fill=(r, g, b))
1338
+ # A few accent circles for visual interest
1339
+ for _ in range(5):
1340
+ x, y = random.randint(40, 472), random.randint(40, 472)
1341
+ rad = random.randint(20, 70)
1342
+ col = (random.randint(150, 255), random.randint(150, 255), random.randint(150, 255))
1343
+ draw.ellipse([x - rad, y - rad, x + rad, y + rad], fill=col)
1344
+ draw.text((20, 470), f"Concept: {prompt[:40]}", fill=(220, 220, 220))
1345
+ img.save(filepath)
1346
+ return jsonify({
1347
+ "success": True,
1348
+ "image_url": f"/static/generated/{filename}",
1349
+ "prompt": prompt,
1350
+ "placeholder": True,
1351
+ "note": "AI image generation is unavailable on this host, so this is a concept placeholder (not a real AI image). Enable ZO image generation or set NEURALAI_DIFFUSION=1 for local models."
1352
+ })
1353
+ except Exception as e:
1354
+ return jsonify({"success": False, "error": f"Image generation failed: {e}"})
1355
+
1356
+ # ====================
1357
+ # ROUTES - AUTH
1358
+ # ====================
1359
+ @app.route("/api/auth/guest", methods=["POST"])
1360
+ def guest_login():
1361
+ code = uuid.uuid4().hex[:8]
1362
+ user_id = f"guest_{os.urandom(4).hex()}"
1363
+ token = jwt.encode({"user_id": user_id, "role": "maestro"}, app.config["SECRET_KEY"], algorithm="HS256")
1364
+ return jsonify({"token": token, "user": {"username": f"Maestro_{code[:4]}", "role": "maestro"}})
1365
+
1366
+ @app.route("/api/auth/signup", methods=["POST"])
1367
+ def signup():
1368
+ data = request.get_json(silent=True) or {}
1369
+ username = data.get("username", "").strip()
1370
+ email = data.get("email", "").strip()
1371
+ password = data.get("password", "")
1372
+ if not username or not password:
1373
+ return jsonify({"error": "Missing fields"}), 400
1374
+ is_founder = 1 if email == FOUNDER_EMAIL else 0
1375
+ hashed = generate_password_hash(password)
1376
+ uid = "user_" + uuid.uuid4().hex[:8]
1377
+ now = datetime.now(timezone.utc).isoformat()
1378
+ db = get_db()
1379
+ try:
1380
+ db.execute("INSERT INTO users (id, username, email, is_founder, password_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)",
1381
+ (uid, username, email, is_founder, hashed, now))
1382
+ db.commit()
1383
+ token = jwt.encode({"user_id": uid, "is_founder": is_founder, "exp": datetime.now(timezone.utc) + timedelta(days=30)},
1384
+ app.config["SECRET_KEY"], algorithm="HS256")
1385
+ return jsonify({"success": True, "message": "User created", "token": token,
1386
+ "user": {"id": uid, "username": username, "is_founder": bool(is_founder)}})
1387
+ except sqlite3.IntegrityError:
1388
+ return jsonify({"error": "Username or email exists"}), 409
1389
+ finally:
1390
+ db.close()
1391
+
1392
+ @app.route("/api/auth/login", methods=["POST"])
1393
+ def login():
1394
+ data = request.get_json(silent=True) or {}
1395
+ identity = (data.get("username") or data.get("email") or "").strip()
1396
+ password = data.get("password", "")
1397
+ if not identity or not password:
1398
+ return jsonify({"error": "Missing credentials"}), 400
1399
+ db = get_db()
1400
+ try:
1401
+ user = db.execute("SELECT * FROM users WHERE username = ? OR email = ?", (identity, identity)).fetchone()
1402
+ if user and check_password_hash(user["password_hash"], password):
1403
+ # Auto-promote the founder account on login (in case it predates the flag)
1404
+ if user["email"] == FOUNDER_EMAIL and not user["is_founder"]:
1405
+ db.execute("UPDATE users SET is_founder = 1 WHERE id = ?", (user["id"],))
1406
+ db.commit()
1407
+ user = db.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
1408
+ token = jwt.encode({"user_id": user["id"], "is_founder": user["is_founder"],
1409
+ "exp": datetime.now(timezone.utc) + timedelta(days=30)},
1410
+ app.config["SECRET_KEY"], algorithm="HS256")
1411
+ return jsonify({"success": True, "token": token,
1412
+ "user": {"id": user["id"], "username": user["username"], "is_founder": bool(user["is_founder"])}})
1413
+ return jsonify({"error": "Invalid credentials"}), 401
1414
+ finally:
1415
+ db.close()
1416
+
1417
+ @app.route("/api/auth/maestro", methods=["POST"])
1418
+ def maestro_login():
1419
+ data = request.get_json(silent=True) or {}
1420
+ code_in = (data.get("code") or data.get("maestro_id") or "").strip()
1421
+ if not code_in:
1422
+ return jsonify({"error": "Maestro ID required"}), 400
1423
+ user_id = f"maestro_{os.urandom(4).hex()}"
1424
+ token = jwt.encode({"user_id": user_id, "role": "maestro"}, app.config["SECRET_KEY"], algorithm="HS256")
1425
+ return jsonify({"token": token, "user": {"username": code_in, "role": "maestro"}})
1426
+
1427
+ # ====================
1428
+ # ROUTES - USER
1429
+ # ====================
1430
+ @app.route("/api/user/me", methods=["GET"])
1431
+ @token_required
1432
+ def get_user_me(current_user):
1433
+ db = get_db()
1434
+ try:
1435
+ user = db.execute("SELECT * FROM users WHERE id = ?", (current_user,)).fetchone()
1436
+ if not user:
1437
+ return jsonify({"user": {"id": current_user, "username": current_user, "is_founder": False}})
1438
+ u_dict = dict(user)
1439
+ if "password_hash" in u_dict: del u_dict["password_hash"]
1440
+ return jsonify({"user": u_dict})
1441
+ finally:
1442
+ db.close()
1443
+
1444
+ @app.route("/api/user/update", methods=["POST"])
1445
+ @token_required
1446
+ def update_user(current_user):
1447
+ data = request.get_json(silent=True) or {}
1448
+ db = get_db()
1449
+ try:
1450
+ for field in ["first_name", "last_name", "bio", "bod", "email"]:
1451
+ if field in data:
1452
+ db.execute(f"UPDATE users SET {field} = ? WHERE id = ?", (data[field], current_user))
1453
+ db.commit()
1454
+ return jsonify({"success": True})
1455
+ finally:
1456
+ db.close()
1457
+
1458
+ # ====================
1459
+ # ROUTES - SETTINGS
1460
+ # ====================
1461
+ @app.route("/api/settings", methods=["GET", "POST"])
1462
+ @token_required
1463
+ def manage_settings(current_user):
1464
+ db = get_db()
1465
+ try:
1466
+ if request.method == "POST":
1467
+ data = request.get_json() or {}
1468
+ now = datetime.now(timezone.utc).isoformat()
1469
+ for k, v in data.items():
1470
+ db.execute("INSERT OR REPLACE INTO user_settings (user_id, key, value, updated_at) VALUES (?, ?, ?, ?)",
1471
+ (current_user, k, str(v), now))
1472
+ db.commit()
1473
+ return jsonify({"success": True})
1474
+ rows = db.execute("SELECT key, value FROM user_settings WHERE user_id = ?", (current_user,)).fetchall()
1475
+ settings = {row["key"]: row["value"] for row in rows}
1476
+ return jsonify({"success": True, "settings": settings})
1477
+ finally:
1478
+ db.close()
1479
+
1480
+ # ====================
1481
+ # ROUTES - API KEY (BYO API)
1482
+ # ====================
1483
+ # NeuralAI can act as an OpenAI-compatible backend for external hosts (e.g. ZO Computer
1484
+ # "BYO API"). A user generates a personal API key here; the key is stored hashed and used
1485
+ # to authenticate requests to /v1/chat/completions. The raw key is shown only once.
1486
+ def _hash_key(key: str) -> str:
1487
+ return hashlib.sha256(key.encode()).hexdigest()
1488
+
1489
+ @app.route("/api/settings/api-key", methods=["POST", "DELETE"])
1490
+ @token_required
1491
+ def manage_api_key(current_user):
1492
+ db = get_db()
1493
+ try:
1494
+ if request.method == "DELETE":
1495
+ db.execute("DELETE FROM user_settings WHERE user_id = ? AND key = 'api_key_hash'", (current_user,))
1496
+ db.commit()
1497
+ return jsonify({"success": True, "message": "API key revoked."})
1498
+
1499
+ # POST -> generate a new key (revoking any previous one)
1500
+ raw = "nai_" + secrets.token_urlsafe(32)
1501
+ db.execute("INSERT OR REPLACE INTO user_settings (user_id, key, value, updated_at) VALUES (?, ?, ?, ?)",
1502
+ (current_user, "api_key_hash", _hash_key(raw), datetime.now(timezone.utc).isoformat()))
1503
+ db.commit()
1504
+ # Return the raw key ONCE. It is never stored or retrievable again.
1505
+ return jsonify({"success": True, "api_key": raw})
1506
+ finally:
1507
+ db.close()
1508
+
1509
+ def _user_for_api_key(api_key: str):
1510
+ """Resolve a raw API key to a user_id, or None if invalid.
1511
+
1512
+ Accepts two credential types:
1513
+ 1. A NeuralAI-generated personal API key (stored hashed in user_settings).
1514
+ 2. The ZO Computer platform identity token (ZO_CLIENT_IDENTITY_TOKEN) — required
1515
+ when the request passes through ZO's hosting gateway, which rejects any call
1516
+ lacking a valid platform Authorization header. When the platform token is
1517
+ presented, we resolve to the founder account so the gateway's auth and the
1518
+ app's auth both succeed.
1519
+ """
1520
+ if not api_key:
1521
+ return None
1522
+ # 1) ZO platform token (gateway auth)
1523
+ zo_token = os.environ.get("ZO_CLIENT_IDENTITY_TOKEN", "")
1524
+ if zo_token and api_key == zo_token:
1525
+ return "founder"
1526
+ # 2) NeuralAI personal API key (hashed lookup)
1527
+ h = _hash_key(api_key)
1528
+ db = get_db()
1529
+ try:
1530
+ row = db.execute("SELECT user_id FROM user_settings WHERE key = 'api_key_hash' AND value = ?", (h,)).fetchone()
1531
+ return row["user_id"] if row else None
1532
+ finally:
1533
+ db.close()
1534
+
1535
+ @app.route("/v1/models", methods=["GET"])
1536
+ def list_models():
1537
+ """OpenAI-compatible model listing for BYO API hosts."""
1538
+ return jsonify({
1539
+ "object": "list",
1540
+ "data": [{
1541
+ "id": "neuralai",
1542
+ "object": "model",
1543
+ "created": 1700000000,
1544
+ "owned_by": "neuralai",
1545
+ "root": "neuralai",
1546
+ "parent": None,
1547
+ }]
1548
+ })
1549
+
1550
+
1551
+ def _streaming_response(gen, model_id, stream):
1552
+ """Return an SSE stream or a single JSON chat.completion object based on
1553
+ the caller's `stream` flag. Every backend generator yields SSE
1554
+ 'data: {...}' frames, so non-streaming just reassembles them."""
1555
+ if stream:
1556
+ return Response(stream_with_context(gen), mimetype="text/event-stream")
1557
+ parts = []
1558
+ for frame in gen:
1559
+ if not frame.startswith("data:"):
1560
+ continue
1561
+ payload = frame[len("data:"):].strip()
1562
+ if payload == "[DONE]":
1563
+ continue
1564
+ try:
1565
+ obj = json.loads(payload)
1566
+ except Exception:
1567
+ continue
1568
+ for ch in obj.get("choices", []):
1569
+ d = ch.get("delta", {})
1570
+ if d.get("content"):
1571
+ parts.append(d["content"])
1572
+ return jsonify({
1573
+ "id": "chatcmpl-" + secrets.token_hex(8),
1574
+ "object": "chat.completion",
1575
+ "created": int(datetime.now(timezone.utc).timestamp()),
1576
+ "model": model_id or "neuralai",
1577
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "".join(parts)}, "finish_reason": "stop"}],
1578
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
1579
+ })
1580
+
1581
+ @app.route("/v1/chat/completions", methods=["GET"])
1582
+ @app.route("/v1/chat/completions/", methods=["GET"])
1583
+ @app.route("/v1/chat/completions/<model_id>", methods=["GET"])
1584
+ @app.route("/v1/chat/completions/<model_id>/", methods=["GET"])
1585
+ @app.route("/v1/chat/completions/chat/completions", methods=["GET"])
1586
+ @app.route("/v1/chat/completions/chat/completions/", methods=["GET"])
1587
+ @app.route("/v1", methods=["GET"])
1588
+ @app.route("/v1/", methods=["GET"])
1589
+ def openai_chat_completions_get(model_id=None):
1590
+ # Health / capability probe — ZO Computer's BYOK validation does a GET on the
1591
+ # endpoint (base URL or /v1/chat/completions). Return 200 so validation
1592
+ # passes; the actual chat runs over POST (openai_chat_completions below).
1593
+ return jsonify({
1594
+ "object": "list",
1595
+ "data": [{"id": "neuralai", "object": "model", "owned_by": "neuralai", "root": "neuralai", "parent": None}],
1596
+ "status": "ok",
1597
+ })
1598
+
1599
+ @app.route("/v1/chat/completions", methods=["POST"])
1600
+ @app.route("/v1/chat/completions/", methods=["POST"])
1601
+ @app.route("/v1/chat/completions/<model_id>", methods=["POST"])
1602
+ @app.route("/v1/chat/completions/<model_id>/", methods=["POST"])
1603
+ @app.route("/v1/chat/completions/chat/completions", methods=["POST"])
1604
+ @app.route("/v1/chat/completions/chat/completions/", methods=["POST"])
1605
+ @app.route("/v1", methods=["POST"])
1606
+ @app.route("/v1/", methods=["POST"])
1607
+ def openai_chat_completions(model_id=None):
1608
+ """OpenAI-compatible chat completions endpoint for external BYO API hosts (e.g. ZO Computer).
1609
+
1610
+ Auth: Authorization: Bearer <api_key> OR ?api_key=<api_key>
1611
+ Accepts {model, messages, max_tokens, temperature, stream}.
1612
+ Uses the same local model + NeuralAI system prompt as the in-app chat.
1613
+ """
1614
+ # --- API key auth ---
1615
+ # Accept: Authorization: Bearer <key> | Authorization: <key> | x-api-key: <key> | ?api_key= | body.api_key
1616
+ auth = request.headers.get("Authorization", "")
1617
+ api_key = auth.replace("Bearer ", "", 1).strip() if auth else ""
1618
+ if not api_key:
1619
+ api_key = request.headers.get("X-Api-Key", "").strip()
1620
+ if not api_key:
1621
+ api_key = request.args.get("api_key", "").strip()
1622
+ if not api_key:
1623
+ api_key = (request.get_json(silent=True) or {}).get("api_key", "").strip()
1624
+ user_id = _user_for_api_key(api_key)
1625
+ if not user_id:
1626
+ # The ZO native backend authenticates via the platform identity token
1627
+ # (ZO_CLIENT_IDENTITY_TOKEN), not a user-supplied key, so it must be
1628
+ # allowed unkeyed just like the local backend. Otherwise every chat
1629
+ # request returns "Invalid API key" (the recurring unauthorized error).
1630
+ if LLM_BACKEND in ("local", "zo"):
1631
+ user_id = "founder"
1632
+ else:
1633
+ return jsonify({"error": "Invalid API key"}), 401
1634
+
1635
+ data = request.get_json(silent=True) or {}
1636
+ messages = data.get("messages", [])
1637
+ model_id = data.get("model", "neuralai") # request model ID (not the global model object)
1638
+ # Local CPU backend is slow: cap generation lower so responses stream fast.
1639
+ _mt_default = 48 if LLM_BACKEND == "local" else 512
1640
+ _mt_cap = 80 if LLM_BACKEND == "local" else 2048
1641
+ max_tokens = min(int(data.get("max_tokens", _mt_default)), _mt_cap)
1642
+ temperature = float(data.get("temperature", 0.3)) # lower default for faster CPU inference
1643
+ # Default to streaming (BYO API hosts like ZO Computer show tokens as they
1644
+ # arrive), but honor the caller's `stream` flag so non-streaming OpenAI
1645
+ # clients also work.
1646
+ stream = bool(data.get("stream", True))
1647
+
1648
+ # Build the same system prompt the in-app chat uses
1649
+ db = get_db()
1650
+ try:
1651
+ user = db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
1652
+ mem_rows = db.execute("SELECT fact FROM memory_facts WHERE user_id = ?", (user_id,)).fetchall()
1653
+ rule_rows = db.execute("SELECT rule FROM active_rules WHERE user_id = ? AND active = 1", (user_id,)).fetchall()
1654
+ finally:
1655
+ db.close()
1656
+ mem_facts = [r["fact"] for r in mem_rows]
1657
+ active_rules = [r["rule"] for r in rule_rows]
1658
+ system_content = NEURALAI_SYSTEM_PROMPT
1659
+ if mem_facts:
1660
+ system_content += "\n\n## User Memory\n" + "\n".join(f"- {m}" for m in mem_facts)
1661
+ if active_rules:
1662
+ system_content += "\n\n## Active Rules\n" + "\n".join(f"- {r}" for r in active_rules)
1663
+
1664
+ # Assemble ChatML messages for the local model
1665
+ chat_messages = [{"role": "system", "content": system_content}]
1666
+ for m in messages:
1667
+ role = m.get("role", "user")
1668
+ content = m.get("content", "")
1669
+ if isinstance(content, list): # handle multimodal content arrays
1670
+ content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
1671
+ chat_messages.append({"role": role, "content": _cap_text(content)})
1672
+
1673
+ # Truncate to fit the model's context window (prevent OOM from 50K+ token payloads)
1674
+ # Skip when tokenizer is None (external backends handle their own limits)
1675
+ if tokenizer is not None:
1676
+ chat_messages = _truncate_to_fit(chat_messages, tokenizer)
1677
+
1678
+ # === External backend: forward messages directly (no tokenizer needed) ===
1679
+ if LLM_BACKEND in ("ollama", "lmstudio", "openai_compatible"):
1680
+ def gen_external():
1681
+ try:
1682
+ resp = _forward_to_external_llm(chat_messages, max_tokens=max_tokens, temperature=temperature, stream=True)
1683
+ if resp.status_code != 200:
1684
+ err = f"Backend error ({resp.status_code}): {resp.text[:200]}"
1685
+ yield "data: " + json.dumps({"choices": [{"delta": {"content": err}, "finish_reason": None}]}) + "\n\n"
1686
+ else:
1687
+ for line in resp.iter_lines():
1688
+ if not line or not line.startswith(b"data: "):
1689
+ continue
1690
+ payload = line[6:].decode().strip()
1691
+ if payload == "[DONE]":
1692
+ break
1693
+ try:
1694
+ chunk = json.loads(payload)
1695
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
1696
+ content = delta.get("content", "")
1697
+ if content:
1698
+ yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}]}) + "\n\n"
1699
+ except json.JSONDecodeError:
1700
+ continue
1701
+ except Exception as e:
1702
+ yield "data: " + json.dumps({"choices": [{"delta": {"content": f"Error: {e}"}, "finish_reason": None}]}) + "\n\n"
1703
+ yield "data: " + json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}) + "\n\n"
1704
+ yield "data: [DONE]\n\n"
1705
+ return _streaming_response(gen_external(), model_id, stream)
1706
+
1707
+ # === ZO native /zo/ask backend ===
1708
+ if LLM_BACKEND == "zo":
1709
+ def gen_zo():
1710
+ try:
1711
+ resp = _forward_to_zo(chat_messages, max_tokens=max_tokens, temperature=temperature, stream=True)
1712
+ if resp.status_code != 200:
1713
+ raise RuntimeError(f"ZO backend error ({resp.status_code}): {resp.text[:200]}")
1714
+ content_type = resp.headers.get("content-type", "")
1715
+ if "text/event-stream" in content_type or "chunked" in content_type:
1716
+ for line in resp.iter_lines():
1717
+ if not line or not line.startswith(b"data: "):
1718
+ continue
1719
+ payload = line[6:].decode().strip()
1720
+ if payload == "[DONE]":
1721
+ break
1722
+ try:
1723
+ chunk = json.loads(payload)
1724
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
1725
+ tok = delta.get("content", "")
1726
+ if not tok:
1727
+ tok = chunk.get("output", "")
1728
+ if tok:
1729
+ yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {"content": tok}, "finish_reason": None}]}) + "\n\n"
1730
+ except json.JSONDecodeError:
1731
+ continue
1732
+ else:
1733
+ data = resp.json()
1734
+ full_output = data.get("output", "")
1735
+ if not full_output and "choices" in data:
1736
+ full_output = data["choices"][0].get("message", {}).get("content", "")
1737
+ if full_output:
1738
+ yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {"content": full_output}, "finish_reason": None}]}) + "\n\n"
1739
+ except Exception as e:
1740
+ # ZO backend failed. Do NOT fall back to the local PyTorch model — on the 4GB ZO
1741
+ # Computer it OOMs and emits incoherent <80-token replies. Surface the error so the
1742
+ # user sees what happened instead of garbage.
1743
+ logger.error(f"[LLM] ZO backend failed, local fallback disabled: {e}")
1744
+ err_msg = (
1745
+ f"NeuralAI is temporarily unavailable: the model backend returned an error "
1746
+ f"({getattr(e, 'response', None) or str(e)[:200]}). Please try again or check the service logs."
1747
+ )
1748
+ yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]}) + "\n\n"
1749
+ yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {"content": err_msg}, "finish_reason": None}]}) + "\n\n"
1750
+ yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}) + "\n\n"
1751
+ yield "data: [DONE]\n\n"
1752
+ return
1753
+ yield "data: " + json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}) + "\n\n"
1754
+ yield "data: [DONE]\n\n"
1755
+ return _streaming_response(gen_zo(), model_id, stream)
1756
+
1757
+ # === Local PyTorch: render via tokenizer ===
1758
+ try:
1759
+ prompt = tokenizer.apply_chat_template(chat_messages, tokenize=False, add_generation_prompt=True)
1760
+ except Exception:
1761
+ # Fallback manual ChatML assembly
1762
+ out = []
1763
+ for i, msg in enumerate(chat_messages):
1764
+ if i == 0 and msg["role"] != "system":
1765
+ out.append("<|im_start|>system\nYou are a helpful AI assistant named NeuralAI<|im_end|>\n")
1766
+ out.append(f"<|im_start|>{msg['role']}\n{msg['content']}<|im_end|>\n")
1767
+ out.append("<|im_start|>assistant\n")
1768
+ prompt = "".join(out)
1769
+
1770
+ # Streaming (SSE) — always enabled for BYO API compatibility
1771
+ # CRITICAL: already_rendered=True because prompt was built via apply_chat_template above.
1772
+ # Passing it through build_prompt_with_context again would double-wrap in ChatML.
1773
+ def gen():
1774
+ yield "data: " + json.dumps({"id": "chatcmpl-" + secrets.token_hex(8), "object": "chat.completion.chunk",
1775
+ "created": int(datetime.now(timezone.utc).timestamp()), "model": model_id,
1776
+ "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]}) + "\n\n"
1777
+ for chunk in stream_response(prompt, max_tokens=max_tokens, temperature=temperature, already_rendered=True):
1778
+ content = re.sub(r"<tool>.*?</tool>", "", chunk, flags=re.DOTALL)
1779
+ if content:
1780
+ yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}]}) + "\n\n"
1781
+ yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}) + "\n\n"
1782
+ yield "data: [DONE]\n\n"
1783
+
1784
+ return _streaming_response(gen(), model_id, stream)
1785
+
1786
+ # ====================
1787
+ # ROUTES - SELF UPDATE (Founder only)
1788
+ # ====================
1789
+ @app.route("/api/admin/update", methods=["POST"])
1790
+ @token_required
1791
+ def admin_self_update(current_user):
1792
+ """Pull the latest code from origin/master and restart this service in place.
1793
+
1794
+ Gated to founder accounts only. The restart is performed by re-exec'ing the
1795
+ current process (os.execv) so the host's process manager keeps the same PID/socket.
1796
+ """
1797
+ db = get_db()
1798
+ try:
1799
+ user = db.execute("SELECT is_founder FROM users WHERE id = ?", (current_user,)).fetchone()
1800
+ finally:
1801
+ db.close()
1802
+ if not user or not user["is_founder"]:
1803
+ return jsonify({"success": False, "error": "Founder access required"}), 403
1804
+
1805
+ try:
1806
+ pull = subprocess.run(
1807
+ ["git", "pull", "origin", "master"],
1808
+ cwd=str(REPO_ROOT), capture_output=True, text=True, timeout=120
1809
+ )
1810
+ pull_out = (pull.stdout + pull.stderr).strip()
1811
+ if pull.returncode != 0:
1812
+ return jsonify({"success": False, "error": "git pull failed", "detail": pull_out}), 500
1813
+ except Exception as e:
1814
+ return jsonify({"success": False, "error": f"git pull error: {e}"}), 500
1815
+
1816
+ # Restart in place: re-exec the current interpreter with the same argv.
1817
+ # The host's process manager (or ZO entrypoint) will keep serving on the same port.
1818
+ try:
1819
+ logger.info("Self-update: git pull succeeded, restarting in place...")
1820
+ os.execv(sys.executable, [sys.executable] + sys.argv)
1821
+ except Exception as e:
1822
+ return jsonify({"success": False, "error": f"restart failed: {e}", "pull": pull_out}), 500
1823
+
1824
+ # ====================
1825
+ # ROUTES - MEMORY
1826
+ # ====================
1827
+ @app.route("/api/memory", methods=["GET", "POST"])
1828
+ @token_required
1829
+ def manage_memory(current_user):
1830
+ db = get_db()
1831
+ try:
1832
+ if request.method == "POST":
1833
+ data = request.get_json() or {}
1834
+ fact = data.get("fact")
1835
+ if not fact: return jsonify({"error": "Missing fact"}), 400
1836
+ now = datetime.now(timezone.utc).isoformat()
1837
+ db.execute("INSERT INTO memory_facts (fact, user_id, created_at) VALUES (?, ?, ?)", (fact, current_user, now))
1838
+ db.commit()
1839
+ return jsonify({"success": True})
1840
+ rows = db.execute("SELECT id, fact, created_at FROM memory_facts WHERE user_id = ? ORDER BY created_at DESC", (current_user,)).fetchall()
1841
+ return jsonify({"success": True, "facts": [dict(row) for row in rows]})
1842
+ finally:
1843
+ db.close()
1844
+
1845
+ @app.route("/api/memory/<int:id>", methods=["DELETE"])
1846
+ @token_required
1847
+ def delete_memory(current_user, id):
1848
+ db = get_db()
1849
+ try:
1850
+ db.execute("DELETE FROM memory_facts WHERE id = ? AND user_id = ?", (id, current_user))
1851
+ db.commit()
1852
+ return jsonify({"success": True})
1853
+ finally:
1854
+ db.close()
1855
+
1856
+ # ====================
1857
+ # ROUTES - RULES
1858
+ # ====================
1859
+ @app.route("/api/rules", methods=["GET", "POST"])
1860
+ @token_required
1861
+ def manage_rules(current_user):
1862
+ db = get_db()
1863
+ try:
1864
+ if request.method == "POST":
1865
+ data = request.get_json() or {}
1866
+ rule = data.get("rule")
1867
+ if not rule: return jsonify({"error": "Missing rule"}), 400
1868
+ now = datetime.now(timezone.utc).isoformat()
1869
+ db.execute("INSERT INTO active_rules (rule, user_id, created_at) VALUES (?, ?, ?)", (rule, current_user, now))
1870
+ db.commit()
1871
+ return jsonify({"success": True})
1872
+ rows = db.execute("SELECT id, rule, active, created_at FROM active_rules WHERE user_id = ? ORDER BY created_at DESC", (current_user,)).fetchall()
1873
+ return jsonify({"success": True, "rules": [dict(row) for row in rows]})
1874
+ finally:
1875
+ db.close()
1876
+
1877
+ @app.route("/api/rules/<int:id>", methods=["DELETE"])
1878
+ @token_required
1879
+ def delete_rule(current_user, id):
1880
+ db = get_db()
1881
+ try:
1882
+ db.execute("DELETE FROM active_rules WHERE id = ? AND user_id = ?", (id, current_user))
1883
+ db.commit()
1884
+ return jsonify({"success": True})
1885
+ finally:
1886
+ db.close()
1887
+
1888
+ @app.route("/api/rules/<int:id>/toggle", methods=["POST"])
1889
+ @token_required
1890
+ def toggle_rule(current_user, id):
1891
+ db = get_db()
1892
+ try:
1893
+ row = db.execute("SELECT active FROM active_rules WHERE id = ? AND user_id = ?", (id, current_user)).fetchone()
1894
+ if row:
1895
+ new_status = 0 if row["active"] else 1
1896
+ db.execute("UPDATE active_rules SET active = ? WHERE id = ? AND user_id = ?", (new_status, id, current_user))
1897
+ db.commit()
1898
+ return jsonify({"success": True})
1899
+ finally:
1900
+ db.close()
1901
+
1902
+ # ====================
1903
+ # ROUTES - UPLOAD
1904
+ # ====================
1905
+ @app.route("/api/upload", methods=["POST"])
1906
+ def upload_file():
1907
+ if 'file' not in request.files:
1908
+ return jsonify({"error": "No file"}), 400
1909
+ file = request.files['file']
1910
+ save_path = STORAGE_ROOT / file.filename
1911
+ file.save(str(save_path))
1912
+ return jsonify({"success": True, "name": file.filename, "size": save_path.stat().st_size})
1913
+
1914
+ # ====================
1915
+ # WEBSOCKET PROXY - Voice Service
1916
+ # ====================
1917
+ # Proxies WebSocket connections from /voice/ws to the local voice service on port 5001
1918
+ VOICE_SERVICE = os.environ.get("VOICE_SERVICE_URL", "ws://127.0.0.1:5001/ws")
1919
+
1920
+ @app.route("/voice/ws")
1921
+ def voice_ws_proxy():
1922
+ """Upgrade HTTP to WebSocket and proxy to voice service."""
1923
+ from flask_sock import Sock
1924
+ import websocket as ws_lib
1925
+
1926
+ # This endpoint is handled by flask_sock via the sock instance below
1927
+ pass
1928
+
1929
+ sock = Sock(app)
1930
+
1931
+ @sock.route("/voice/ws")
1932
+ def voice_proxy(ws):
1933
+ """Proxy WebSocket between browser and voice service on localhost:5001."""
1934
+ import websocket as ws_lib
1935
+ import threading
1936
+
1937
+ logger.info("[VoiceProxy] Browser connected, opening upstream to %s", VOICE_SERVICE)
1938
+
1939
+ # Connect upstream to the voice service
1940
+ upstream = ws_lib.create_connection(VOICE_SERVICE, timeout=30)
1941
+
1942
+ def recv_from_upstream():
1943
+ try:
1944
+ while True:
1945
+ data = upstream.recv()
1946
+ if not data:
1947
+ break
1948
+ ws.send(data)
1949
+ except Exception as e:
1950
+ logger.info("[VoiceProxy] Upstream closed: %s", e)
1951
+ finally:
1952
+ try:
1953
+ ws.close()
1954
+ except:
1955
+ pass
1956
+
1957
+ t = threading.Thread(target=recv_from_upstream, daemon=True)
1958
+ t.start()
1959
+
1960
+ try:
1961
+ while True:
1962
+ data = ws.receive()
1963
+ if data is None:
1964
+ break
1965
+ upstream.send(data)
1966
+ except Exception as e:
1967
+ logger.info("[VoiceProxy] Browser disconnected: %s", e)
1968
+ finally:
1969
+ try:
1970
+ upstream.close()
1971
+ except:
1972
+ pass
1973
+
1974
  # ====================
1975
  # STARTUP
1976
  # ====================
1977
  if __name__ == "__main__":
1978
  print(f"NeuralAI Unified Service starting on port {PORT}...")
1979
+ init_db()
1980
+ if LLM_BACKEND == "local":
1981
+ load_model()
1982
+ else:
1983
+ logger.info(f"[BOOT] Backend={LLM_BACKEND} — skipping local model load")
1984
+ # Launch defense threads: keep-alive + memory watchdog
1985
+ threading.Thread(target=_keep_alive_pinger, daemon=True).start()
1986
+ threading.Thread(target=_memory_watchdog, daemon=True).start()
1987
+ logger.info("[BOOT] Defense threads launched: keep-alive pinger + memory watchdog")
1988
  app.run(host="0.0.0.0", port=PORT, debug=False, threaded=True)