ablate-llm 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {ablate_llm-0.4.0/src/ablate_llm.egg-info → ablate_llm-0.4.2}/PKG-INFO +5 -5
  2. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/README.md +4 -4
  3. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/pyproject.toml +1 -1
  4. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/__init__.py +1 -1
  5. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/chat/server.py +46 -22
  6. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/chat/static/index.html +117 -31
  7. ablate_llm-0.4.2/src/ablate/chat/static/logo-dark.png +0 -0
  8. ablate_llm-0.4.2/src/ablate/chat/static/logo-light.png +0 -0
  9. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/chat/static/terms.html +1 -1
  10. ablate_llm-0.4.2/src/ablate/chat/store.py +314 -0
  11. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/chat/tools.py +20 -0
  12. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/cli.py +6 -0
  13. {ablate_llm-0.4.0 → ablate_llm-0.4.2/src/ablate_llm.egg-info}/PKG-INFO +5 -5
  14. ablate_llm-0.4.0/src/ablate/chat/store.py +0 -193
  15. ablate_llm-0.4.0/src/ablate/ui/static/logo-dark.png +0 -0
  16. ablate_llm-0.4.0/src/ablate/ui/static/logo-light.png +0 -0
  17. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/LICENSE +0 -0
  18. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/NOTICE +0 -0
  19. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/setup.cfg +0 -0
  20. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/_data/__init__.py +0 -0
  21. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/_data/harmful.txt +0 -0
  22. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/_data/harmless.txt +0 -0
  23. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/chat/__init__.py +0 -0
  24. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/chat/static/mark.png +0 -0
  25. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/config.py +0 -0
  26. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/data.py +0 -0
  27. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/evaluate.py +0 -0
  28. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/extract.py +0 -0
  29. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/harness.py +0 -0
  30. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/hooks.py +0 -0
  31. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/hub.py +0 -0
  32. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/judges.py +0 -0
  33. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/model.py +0 -0
  34. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/optimize.py +0 -0
  35. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/pipeline.py +0 -0
  36. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/subspace.py +0 -0
  37. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/ui/__init__.py +0 -0
  38. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/ui/server.py +0 -0
  39. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/ui/static/architecture.svg +0 -0
  40. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/ui/static/index.html +0 -0
  41. {ablate_llm-0.4.0/src/ablate/chat → ablate_llm-0.4.2/src/ablate/ui}/static/logo-dark.png +0 -0
  42. {ablate_llm-0.4.0/src/ablate/chat → ablate_llm-0.4.2/src/ablate/ui}/static/logo-light.png +0 -0
  43. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/utils.py +0 -0
  44. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate/weights.py +0 -0
  45. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate_llm.egg-info/SOURCES.txt +0 -0
  46. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate_llm.egg-info/dependency_links.txt +0 -0
  47. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate_llm.egg-info/entry_points.txt +0 -0
  48. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate_llm.egg-info/requires.txt +0 -0
  49. {ablate_llm-0.4.0 → ablate_llm-0.4.2}/src/ablate_llm.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ablate-llm
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Open-source, local chat with unrestricted small language models (ablate chat), plus a directional ablation (abliteration) toolkit to remove refusal from open LLMs.
5
5
  Author: AI Anytime
6
6
  License: Apache-2.0
@@ -117,8 +117,8 @@ ablate chat # opens http://localhost:7861
117
117
  </p>
118
118
 
119
119
  A private chat app for the AI Anytime **unrestricted small language models**. Models download once from
120
- HuggingFace, then everything runs offline on your CPU or GPU. Chats, memories and your profile are JSON
121
- files in `~/.ablate/chat`. No telemetry, no account, no server of ours.
120
+ HuggingFace, then everything runs offline on your CPU or GPU. Chats, memories, settings and your profile
121
+ (with avatar) live in one local SQLite file, `~/.ablate/chat/ablate.db`. No telemetry, no account, no server of ours.
122
122
 
123
123
  | Model | Params | Refusals before → after | Download | RAM | Licence |
124
124
  |---|---|---|---|---|---|
@@ -128,12 +128,12 @@ files in `~/.ablate/chat`. No telemetry, no account, no server of ours.
128
128
 
129
129
  Refusal rates are on held-out harmful prompts with a keyword judge, from each model's card.
130
130
 
131
- - **Chat history**: saved locally, with search, pin, rename, delete and Markdown export.
131
+ - **Chat history**: saved locally, with ChatGPT-style search (⌘K) across titles and messages, pin, rename, delete and Markdown export.
132
132
  - **Context window you set**, with a usage meter; the oldest turns are dropped first. Follow-ups see the whole conversation.
133
133
  - **Memory**: saved facts added to every chat.
134
134
  - **Agentic tools**: calculator, date and time, search past chats. Web fetch exists but is off by default.
135
135
  - **Copy** answers and code blocks, **edit** and resend, **regenerate**, **stop**, attach text and code files.
136
- - **Local sign-in** with an optional passcode and an 18+ terms gate. `ablate chat --share` lets devices on your Wi-Fi sign in with that passcode; model management stays on the host.
136
+ - **Local profile** with a photo, an optional passcode (`ablate chat --reset-passcode` if you forget it) and an 18+ terms gate. `ablate chat --share` lets devices on your Wi-Fi sign in with that passcode; model management stays on the host.
137
137
  - **OpenAI-compatible API** with streaming at `http://localhost:7861/v1`:
138
138
 
139
139
  ```python
@@ -74,8 +74,8 @@ ablate chat # opens http://localhost:7861
74
74
  </p>
75
75
 
76
76
  A private chat app for the AI Anytime **unrestricted small language models**. Models download once from
77
- HuggingFace, then everything runs offline on your CPU or GPU. Chats, memories and your profile are JSON
78
- files in `~/.ablate/chat`. No telemetry, no account, no server of ours.
77
+ HuggingFace, then everything runs offline on your CPU or GPU. Chats, memories, settings and your profile
78
+ (with avatar) live in one local SQLite file, `~/.ablate/chat/ablate.db`. No telemetry, no account, no server of ours.
79
79
 
80
80
  | Model | Params | Refusals before → after | Download | RAM | Licence |
81
81
  |---|---|---|---|---|---|
@@ -85,12 +85,12 @@ files in `~/.ablate/chat`. No telemetry, no account, no server of ours.
85
85
 
86
86
  Refusal rates are on held-out harmful prompts with a keyword judge, from each model's card.
87
87
 
88
- - **Chat history**: saved locally, with search, pin, rename, delete and Markdown export.
88
+ - **Chat history**: saved locally, with ChatGPT-style search (⌘K) across titles and messages, pin, rename, delete and Markdown export.
89
89
  - **Context window you set**, with a usage meter; the oldest turns are dropped first. Follow-ups see the whole conversation.
90
90
  - **Memory**: saved facts added to every chat.
91
91
  - **Agentic tools**: calculator, date and time, search past chats. Web fetch exists but is off by default.
92
92
  - **Copy** answers and code blocks, **edit** and resend, **regenerate**, **stop**, attach text and code files.
93
- - **Local sign-in** with an optional passcode and an 18+ terms gate. `ablate chat --share` lets devices on your Wi-Fi sign in with that passcode; model management stays on the host.
93
+ - **Local profile** with a photo, an optional passcode (`ablate chat --reset-passcode` if you forget it) and an 18+ terms gate. `ablate chat --share` lets devices on your Wi-Fi sign in with that passcode; model management stays on the host.
94
94
  - **OpenAI-compatible API** with streaming at `http://localhost:7861/v1`:
95
95
 
96
96
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ablate-llm"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Open-source, local chat with unrestricted small language models (ablate chat), plus a directional ablation (abliteration) toolkit to remove refusal from open LLMs."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -23,7 +23,7 @@ from .pipeline import Ablator, run
23
23
  from .weights import bake_direction, bake_subspace
24
24
  from .hub import build_model_card, push_to_hub
25
25
 
26
- __version__ = "0.4.0"
26
+ __version__ = "0.4.2"
27
27
 
28
28
  __all__ = [
29
29
  "Ablator",
@@ -39,7 +39,8 @@ MODELS = [
39
39
  "ram": "16 GB RAM"},
40
40
  {"id": "ai-anytime/Llama-3.2-1B-Instruct-abliterated", "name": "Ablate Llama 1B", "params": "1.2B",
41
41
  "size_gb": 2.5, "license": "Llama 3.2 Community", "context": 131072, "base": "meta-llama/Llama-3.2-1B-Instruct",
42
- "blurb": "Fastest. Runs on almost any laptop. Refusals cut from 75% to 34%.", "ram": "8 GB RAM"},
42
+ "blurb": "Fastest. Runs on almost any laptop. Refusals cut from 75% to 34%. Too small for tools.",
43
+ "ram": "8 GB RAM", "tools": False},
43
44
  ]
44
45
  ALLOW = ["*.json", "*.safetensors", "*.jinja", "*.txt", "*.model", "*.md", "tokenizer*"]
45
46
 
@@ -244,7 +245,10 @@ SESSIONS: Dict[str, float] = {}
244
245
  # Chat turn (with tools)
245
246
  # --------------------------------------------------------------------------- #
246
247
  def _system_prompt(opts: dict, with_tools: bool) -> str:
247
- parts = [opts.get("system") or ""]
248
+ import datetime as _dt
249
+ now = _dt.datetime.now().astimezone()
250
+ parts = [opts.get("system") or "",
251
+ f"Current local date and time: {now:%A, %d %B %Y, %H:%M} ({now.tzname()})."]
248
252
  if opts.get("use_memory", True):
249
253
  mem = [m["text"] for m in store.memories() if m.get("on")]
250
254
  if mem:
@@ -277,16 +281,20 @@ def chat_turn(body: dict, emit: Emit) -> None:
277
281
  store.save_chat(chat)
278
282
  emit({"type": "meta", "chat_id": chat["id"], "title": chat["title"], "index": len(msgs) - 1})
279
283
 
280
- with_tools = bool(opts.get("tools"))
281
- schemas = tools.schemas(bool(opts.get("network_tools"))) if with_tools else None
284
+ # Small models over-use tools, so only offer the tools this message plausibly needs.
285
+ tools_ok = next((m.get("tools", True) for m in MODELS if m["id"] == E.model_id), True)
286
+ schemas = (tools.pick(str(body.get("message") or ""), bool(opts.get("network_tools")))
287
+ if opts.get("tools") and tools_ok else [])
288
+ with_tools = bool(schemas)
282
289
  limit = min(int(opts.get("context_tokens", 4096)), E.max_context())
283
290
  reserve = int(opts.get("max_new_tokens", 768))
284
291
  history = [{"role": m["role"], "content": m["content"]} for m in msgs]
285
- convo, ids, trimmed = E.fit(_system_prompt(opts, with_tools), history, limit, reserve, schemas)
292
+ convo, ids, trimmed = E.fit(_system_prompt(opts, with_tools), history, limit, reserve, schemas or None)
286
293
  emit({"type": "context", "used": int(ids.shape[1]), "limit": limit, "trimmed": trimmed})
287
294
 
288
- steps, stats, answer = [], {}, ""
289
- for _ in range(4): # at most 3 tool calls, then a final answer
295
+ steps, stats, answer, seen = [], {}, "", set()
296
+ for rnd in range(4): # up to 3 tool calls; the last round never offers tools, so it must answer
297
+ offer = with_tools and rnd < 3
290
298
  buf, mode = "", None
291
299
 
292
300
  def on_text(piece: str) -> None:
@@ -294,25 +302,37 @@ def chat_turn(body: dict, emit: Emit) -> None:
294
302
  buf += piece
295
303
  if mode is None:
296
304
  head = buf.lstrip()
297
- if with_tools and tools.looks_like_call(head) and len(head) < 12:
305
+ if offer and tools.looks_like_call(head) and len(head) < 12:
298
306
  return
299
- mode = "tool" if with_tools and tools.looks_like_call(head) else "text"
307
+ mode = "tool" if offer and tools.looks_like_call(head) else "text"
300
308
  if mode == "text":
301
309
  emit({"type": "token", "t": buf})
302
310
  elif mode == "text":
303
311
  emit({"type": "token", "t": piece})
304
312
 
313
+ if rnd and not offer: # final round: same conversation, tools removed
314
+ ids = E.encode(convo)
305
315
  stats = E.stream(ids, opts, on_text)
306
- call = tools.parse_call(stats["text"]) if with_tools and mode != "text" else None
316
+ call = tools.parse_call(stats["text"]) if offer and mode != "text" else None
317
+ key = call and json.dumps([call["name"], call["arguments"]], sort_keys=True)
318
+ if call and key in seen: # repeating itself: stop calling tools and answer
319
+ with_tools = False
320
+ continue
307
321
  if not call:
308
322
  answer = stats["text"].strip()
309
323
  if mode != "text" and answer: # held back as a possible tool call, but it was plain text
310
324
  emit({"type": "token", "t": stats["text"]})
311
- break
325
+ if answer or stats.get("stopped") or not steps:
326
+ break
327
+ with_tools = False # empty reply after tool use: ask once more without tools
328
+ continue
329
+ seen.add(key)
312
330
  emit({"type": "tool_call", "name": call["name"], "args": call["arguments"]})
313
331
  result = tools.run(call["name"], call["arguments"], bool(opts.get("network_tools")))
314
332
  steps.append({"name": call["name"], "args": call["arguments"], "result": result})
315
333
  emit({"type": "tool_result", "name": call["name"], "result": result})
334
+ if isinstance(result, dict) and result.get("error"):
335
+ with_tools = False # a failed call: let the model answer with what it has instead of retrying
316
336
  convo = convo + [
317
337
  {"role": "assistant", "content": "", "tool_calls": [
318
338
  {"type": "function", "function": {"name": call["name"], "arguments": call["arguments"]}}]},
@@ -487,7 +507,7 @@ class Handler(BaseHTTPRequestHandler):
487
507
  return self._static(url.path[len("/static/"):])
488
508
  if url.path == "/api/me":
489
509
  p = store.public_profile()
490
- return self._json({"profile": p and {k: p[k] for k in ("name", "has_passcode", "accepted_terms")},
510
+ return self._json({"profile": p and {k: p[k] for k in ("name", "has_passcode", "accepted_terms", "avatar")},
491
511
  "authed": self._authed(), "local": self._local()})
492
512
  if url.path == "/v1/models":
493
513
  if not self._api_authed():
@@ -503,11 +523,15 @@ class Handler(BaseHTTPRequestHandler):
503
523
  "models": models_state(), "loaded": E.model_id,
504
524
  "device": E.device or E.pick()[0], "max_context": E.max_context() if E.model else None,
505
525
  "chats": store.list_chats(), "busy": E.lock.locked(), "local": self._local(),
526
+ "tools_ok": next((m.get("tools", True) for m in MODELS if m["id"] == E.model_id), True),
506
527
  "port": self.server.server_address[1]})
507
528
  if url.path == "/api/models":
508
529
  return self._json({"models": models_state(), "loaded": E.model_id})
530
+ if url.path == "/api/search":
531
+ q = (urllib.parse.parse_qs(url.query).get("q") or [""])[0]
532
+ return self._json({"results": store.search_chats(q) if q.strip() else []})
509
533
  if url.path.startswith("/api/chats/"):
510
- c = store.get_chat(url.path.rsplit("/", 1)[-1])
534
+ c = store.get_chat(url.path.rsplit("/", 1)[-1], mark_opened=True)
511
535
  return self._json(c or {"error": "not found"}, 200 if c else 404)
512
536
  return self._json({"error": "not found"}, 404)
513
537
  except Exception as e:
@@ -568,25 +592,25 @@ class Handler(BaseHTTPRequestHandler):
568
592
  else:
569
593
  E.unload()
570
594
  return self._json({"models": models_state(), "loaded": E.model_id,
571
- "device": E.device, "max_context": E.max_context() if E.model else None})
595
+ "device": E.device, "max_context": E.max_context() if E.model else None,
596
+ "tools_ok": next((m.get("tools", True) for m in MODELS if m["id"] == E.model_id), True)})
572
597
  if path == "/api/settings":
573
598
  return self._json({"settings": store.save_settings(body)})
574
599
  if path == "/api/memories":
575
600
  return self._json({"memories": store.save_memories(body.get("memories") or [])})
576
601
  if path == "/api/chats/update":
577
- c = store.get_chat(str(body.get("id")))
578
- if not c:
579
- return self._json({"error": "not found"}, 404)
580
- for k in ("title", "pinned"):
581
- if k in body:
582
- c[k] = body[k]
583
- store.save_chat(c)
602
+ store.update_chat(str(body.get("id")), **{k: body[k] for k in ("title", "pinned") if k in body})
584
603
  return self._json({"chats": store.list_chats()})
585
604
  if path == "/api/chats/delete":
586
605
  store.delete_chat(str(body.get("id")))
587
606
  return self._json({"chats": store.list_chats()})
588
607
  if path == "/api/profile":
589
- store.update_profile(body.get("name"), body.get("passcode"))
608
+ avatar = body.get("avatar")
609
+ if avatar and not (isinstance(avatar, str) and len(avatar) < 400_000 and
610
+ avatar.startswith(("data:image/png;base64,", "data:image/jpeg;base64,",
611
+ "data:image/webp;base64,"))):
612
+ return self._json({"error": "Avatar must be a PNG, JPEG or WebP image under 300 KB."}, 400)
613
+ store.update_profile(body.get("name"), body.get("passcode"), avatar)
590
614
  return self._json({"profile": store.public_profile()})
591
615
  return self._json({"error": "not found"}, 404)
592
616
  except (BrokenPipeError, ConnectionResetError):
@@ -24,7 +24,7 @@ code,pre,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
24
24
  /* ---------- sign-in (split) ---------- */
25
25
  #auth{position:fixed;inset:0;display:grid;grid-template-columns:minmax(0,41%) minmax(0,1fr);z-index:30;background:var(--bg)}
26
26
  .auth-l{background:#E9E7E1;color:#14121A;display:flex;flex-direction:column;justify-content:space-between;padding:64px 72px}
27
- .auth-l img{height:64px;width:auto;align-self:flex-start;mix-blend-mode:multiply}
27
+ .auth-l img{height:64px;width:auto;align-self:flex-start}
28
28
  .auth-l h1{font-size:clamp(2.2rem,4.2vw,3.7rem);line-height:1.02;letter-spacing:-.035em;font-weight:500;margin:0}
29
29
  .auth-l p{margin:18px 0 0;color:#4A4758;max-width:36ch}
30
30
  .auth-r{background:#07080C;color:#ECEBF5;display:grid;place-items:center;padding:32px}
@@ -65,10 +65,9 @@ code,pre,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
65
65
  #app.collapsed{grid-template-columns:0 minmax(0,1fr)}
66
66
  #app.collapsed aside{visibility:hidden}
67
67
  aside{background:var(--side);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;overflow:hidden}
68
- .side-top{display:flex;align-items:center;justify-content:space-between;padding:16px 14px 10px 18px}
69
- .side-top img{height:34px;width:auto}
70
- .logo-d{mix-blend-mode:lighten}
71
- .logo-l{mix-blend-mode:multiply}
68
+ .side-top{display:flex;align-items:center;justify-content:space-between;padding:14px 10px 10px 16px}
69
+ .side-top img{height:36px;width:auto}
70
+ .side-top .acts{display:flex;gap:2px}
72
71
  .logo-l{display:none}
73
72
  [data-theme="light"] .logo-l{display:block}
74
73
  [data-theme="light"] .logo-d{display:none}
@@ -97,7 +96,11 @@ aside{background:var(--side);border-right:1px solid var(--line);display:flex;fle
97
96
  .local-card{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;background:var(--panel);border:1px solid var(--line);font-size:.8rem;color:var(--muted)}
98
97
  .local-card b{color:var(--ok);font-weight:600}
99
98
  .me{display:flex;align-items:center;gap:12px;padding:4px 4px}
100
- .avatar{width:42px;height:42px;border-radius:10px;display:grid;place-items:center;font-weight:700;color:#fff;background:linear-gradient(135deg,#5AA9F5,#8B6CFF);flex:none}
99
+ .avatar{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;font-weight:700;color:#fff;background:linear-gradient(135deg,#5AA9F5,#8B6CFF);flex:none;overflow:hidden}
100
+ .avatar img{width:100%;height:100%;object-fit:cover}
101
+ .avatar.lg{width:72px;height:72px;border-radius:18px;font-size:1.6rem}
102
+ #menu{display:none}
103
+ #app.collapsed #menu{display:grid}
101
104
  .me .n{flex:1;min-width:0;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
102
105
  .me small{display:block;font-weight:400;color:var(--muted);font-size:.78rem}
103
106
 
@@ -241,6 +244,25 @@ input[type=range]{width:100%;accent-color:var(--accent)}
241
244
  .codeline code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap}
242
245
  .toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;background:var(--text);color:var(--bg);padding:8px 14px;border-radius:10px;font-size:.86rem;box-shadow:0 10px 30px -10px rgba(0,0,0,.5)}
243
246
 
247
+ /* search dialog (ChatGPT style) */
248
+ .search-sheet{width:min(680px,100%);max-height:72vh;background:var(--panel);border:1px solid var(--line2);border-radius:20px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 30px 80px -20px rgba(0,0,0,.6)}
249
+ .search-top{display:flex;align-items:center;gap:10px;padding:16px 16px 16px 22px;border-bottom:1px solid var(--line)}
250
+ .search-top input{flex:1;border:0;background:none;outline:none;font-size:1.05rem;color:var(--text)}
251
+ .search-top input::placeholder{color:var(--faint)}
252
+ .search-list{overflow-y:auto;padding:8px 10px 14px}
253
+ .search-list h5{margin:14px 12px 6px;font-size:.8rem;font-weight:500;color:var(--faint)}
254
+ .sr{display:flex;align-items:flex-start;gap:14px;width:100%;border:0;background:none;text-align:left;padding:10px 12px;border-radius:12px;color:var(--text)}
255
+ .sr:hover,.sr.on{background:var(--panel2)}
256
+ .sr .ico{margin-top:2px;color:var(--muted)}
257
+ .sr b{display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
258
+ .sr small{display:block;color:var(--muted);font-size:.84rem;margin-top:2px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
259
+ .sr mark{background:color-mix(in srgb,var(--accent) 30%,transparent);color:inherit;border-radius:3px;padding:0 1px}
260
+ .sr span{min-width:0;flex:1}
261
+ .search-empty{padding:28px 12px;color:var(--muted);text-align:center;font-size:.9rem}
262
+ .av-row{display:flex;align-items:center;gap:14px}
263
+ .av-row .btns{display:flex;gap:8px;flex-wrap:wrap}
264
+ .auth-av{width:64px;height:64px;border-radius:16px;overflow:hidden;background:linear-gradient(135deg,#5AA9F5,#8B6CFF);display:grid;place-items:center;font-size:1.5rem;font-weight:700;color:#fff;margin-bottom:6px}
265
+ .auth-av img{width:100%;height:100%;object-fit:cover}
244
266
  @media (max-width:900px){
245
267
  #auth{grid-template-columns:1fr}
246
268
  .auth-l{display:none}
@@ -248,6 +270,7 @@ input[type=range]{width:100%;accent-color:var(--accent)}
248
270
  aside{position:fixed;inset:0 auto 0 0;width:290px;z-index:20;transform:translateX(-100%);transition:transform .2s}
249
271
  #app.open aside{transform:none}
250
272
  #app.collapsed aside{visibility:visible}
273
+ #menu{display:grid}
251
274
  .mgrid{grid-template-columns:1fr}
252
275
  .chip.hide-sm{display:none}
253
276
  }
@@ -283,6 +306,7 @@ input[type=range]{width:100%;accent-color:var(--accent)}
283
306
  <symbol id="i-code" viewBox="0 0 24 24"><path d="m8 8-5 4 5 4M16 8l5 4-5 4"/></symbol>
284
307
  <symbol id="i-calc" viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M8 7h8M8 12h2M14 12h2M8 16h2M14 16h2"/></symbol>
285
308
  <symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
309
+ <symbol id="i-bubble" viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-4.9A8 8 0 1 1 21 12Z"/></symbol>
286
310
  </svg>
287
311
 
288
312
  <!-- ============ sign-in ============ -->
@@ -291,7 +315,7 @@ input[type=range]{width:100%;accent-color:var(--accent)}
291
315
  <img src="/static/logo-light.png" alt="Ablate">
292
316
  <div>
293
317
  <h1>Unrestricted small models. On your machine.</h1>
294
- <p>Open source by AI Anytime. No account, no cloud, no logs: your profile and chats are files on this computer.</p>
318
+ <p>Open source by AI Anytime. No account, no cloud, no logs: your profile and chats live in a database file on this computer.</p>
295
319
  </div>
296
320
  </div>
297
321
  <div class="auth-r">
@@ -311,12 +335,13 @@ input[type=range]{width:100%;accent-color:var(--accent)}
311
335
  <p class="auth-foot">Open source under Apache-2.0 · <a href="https://aianytime.github.io/ablate/" target="_blank" rel="noopener">About Ablate</a></p>
312
336
  </form>
313
337
  <form class="auth-card" id="auth-login" hidden>
338
+ <div class="auth-av" id="l-av" hidden></div>
314
339
  <h2>Welcome back.</h2>
315
340
  <p class="sub" id="l-sub">Unlock your local Ablate Chat.</p>
316
341
  <div class="field"><label for="l-pass">Passcode *</label><input id="l-pass" type="password" autocomplete="current-password" required></div>
317
342
  <div class="auth-err" id="l-err"></div>
318
343
  <button class="btn-light" type="submit">Continue</button>
319
- <p class="auth-foot">Forgot it? Delete <code class="mono">~/.ablate/chat/profile.json</code> on this computer.</p>
344
+ <p class="auth-foot">Forgot it? Run <code class="mono">ablate chat --reset-passcode</code> on this computer.</p>
320
345
  </form>
321
346
  </div>
322
347
  </section>
@@ -339,11 +364,11 @@ input[type=range]{width:100%;accent-color:var(--accent)}
339
364
  <aside>
340
365
  <div class="side-top">
341
366
  <img class="logo-d" src="/static/logo-dark.png" alt="Ablate"><img class="logo-l" src="/static/logo-light.png" alt="Ablate">
342
- <button class="iconbtn" id="collapse" title="Hide sidebar" aria-label="Hide sidebar"><svg class="ico"><use href="#i-panel"/></svg></button>
367
+ <div class="acts"><button class="iconbtn" id="open-search" title="Search chats (⌘K)" aria-label="Search chats"><svg class="ico"><use href="#i-search"/></svg></button>
368
+ <button class="iconbtn" id="collapse" title="Close sidebar" aria-label="Close sidebar"><svg class="ico"><use href="#i-panel"/></svg></button></div>
343
369
  </div>
344
370
  <button class="newchat" id="new"><svg class="ico"><use href="#i-plus"/></svg>New chat</button>
345
371
  <nav class="nav" aria-label="App">
346
- <label><svg class="ico"><use href="#i-search"/></svg><input id="q" placeholder="Search" aria-label="Search chats"></label>
347
372
  <button id="open-models"><svg class="ico"><use href="#i-cube"/></svg>Models</button>
348
373
  <button id="open-memory"><svg class="ico"><use href="#i-brain"/></svg>Memory</button>
349
374
  <button id="open-settings"><svg class="ico"><use href="#i-gear"/></svg>Settings</button>
@@ -359,11 +384,10 @@ input[type=range]{width:100%;accent-color:var(--accent)}
359
384
 
360
385
  <main>
361
386
  <div class="topbar">
362
- <button class="iconbtn" id="menu" title="Chats" aria-label="Show chats"><svg class="ico"><use href="#i-menu"/></svg></button>
387
+ <button class="iconbtn" id="menu" title="Open sidebar" aria-label="Open sidebar"><svg class="ico"><use href="#i-panel"/></svg></button>
363
388
  <button class="modelbtn" id="modelbtn"><span class="dot" id="mdot"></span><span id="mname">Choose a model</span><svg class="ico" style="width:14px;height:14px"><use href="#i-down"/></svg></button>
364
389
  <span class="sp"></span>
365
390
  <span class="chip hide-sm" id="ctx" title="Context window used by the last reply"><span id="ctx-t">0 / 4,096</span><span class="meter"><i id="ctx-bar"></i></span></span>
366
- <span class="chip hide-sm"><svg class="ico" style="color:var(--ok)"><use href="#i-lock"/></svg>Private · on this device</span>
367
391
  <button class="iconbtn" id="export" title="Export chat as Markdown" aria-label="Export chat"><svg class="ico"><use href="#i-dl"/></svg></button>
368
392
  <button class="iconbtn" id="theme" title="Theme" aria-label="Switch theme"><svg class="ico"><use href="#i-moon"/></svg></button>
369
393
  </div>
@@ -409,17 +433,33 @@ input[type=range]{width:100%;accent-color:var(--accent)}
409
433
  <!-- memory -->
410
434
  <div class="modal" id="m-memory" hidden role="dialog" aria-modal="true" aria-labelledby="mem-t">
411
435
  <div class="sheet" style="width:min(620px,100%)">
412
- <header><div><h2 id="mem-t">Memory</h2><p>Facts added to every chat so the model remembers them. Stored in <code class="mono">~/.ablate/chat/memory.json</code>.</p></div><span class="sp"></span><button class="iconbtn" data-close aria-label="Close"><svg class="ico"><use href="#i-x"/></svg></button></header>
436
+ <header><div><h2 id="mem-t">Memory</h2><p>Facts added to every chat so the model remembers them. Stored on this computer in <code class="mono">~/.ablate/chat</code>.</p></div><span class="sp"></span><button class="iconbtn" data-close aria-label="Close"><svg class="ico"><use href="#i-x"/></svg></button></header>
413
437
  <div class="toggle-row"><span>Use memory in chats</span><label class="switch"><input type="checkbox" id="use-mem"><i></i></label></div>
414
438
  <div class="memlist" id="memlist"></div>
415
439
  <div class="custom"><input id="mem-new" placeholder="e.g. I write Python and prefer short answers."><button class="btn primary" id="mem-add">Add</button></div>
416
440
  </div>
417
441
  </div>
418
442
 
443
+ <!-- search -->
444
+ <div class="modal" id="m-search" hidden role="dialog" aria-modal="true" aria-label="Search chats">
445
+ <div class="search-sheet">
446
+ <div class="search-top"><input id="sq" placeholder="Search chats…" autocomplete="off" aria-label="Search chats"><button class="iconbtn" data-close aria-label="Close"><svg class="ico"><use href="#i-x"/></svg></button></div>
447
+ <div class="search-list" id="slist"></div>
448
+ </div>
449
+ </div>
450
+
419
451
  <!-- settings drawer -->
420
452
  <div class="drawer" id="settings" hidden role="dialog" aria-labelledby="set-t">
421
453
  <header><h2 id="set-t">Settings</h2><button class="iconbtn" data-close aria-label="Close"><svg class="ico"><use href="#i-x"/></svg></button></header>
422
454
  <div class="body">
455
+ <div class="group"><h4>Profile</h4>
456
+ <div class="av-row"><div class="avatar lg" id="p-av">A</div><div class="btns">
457
+ <button class="btn" type="button" id="p-av-up">Upload photo</button><button class="btn" type="button" id="p-av-rm">Remove</button>
458
+ <input type="file" id="p-av-file" accept="image/png,image/jpeg,image/webp" hidden></div></div>
459
+ <div class="set"><div class="lbl">Display name</div><input type="text" id="p-name" maxlength="40"></div>
460
+ <div class="set"><div class="lbl">New passcode</div><input type="password" id="p-pass" autocomplete="new-password" placeholder="Leave empty to keep it"><small>A passcode locks the app and is required with <code class="mono">--share</code>.</small></div>
461
+ <div><button class="btn" id="p-save">Save profile</button></div>
462
+ </div>
423
463
  <div class="group"><h4>Model behaviour</h4>
424
464
  <div class="set"><div class="lbl">System prompt</div><textarea id="s-system"></textarea></div>
425
465
  <div class="set"><div class="lbl">Context window <b id="v-ctx"></b></div><input type="range" id="s-ctx" min="512" max="32768" step="512"><small>How much of the conversation the model sees. Older messages are dropped first. Bigger uses more memory.</small></div>
@@ -432,11 +472,6 @@ input[type=range]{width:100%;accent-color:var(--accent)}
432
472
  <div class="toggle-row"><span>Local tools<small>Calculator, date and time, search your past chats.</small></span><label class="switch"><input type="checkbox" id="s-tools"><i></i></label></div>
433
473
  <div class="toggle-row"><span>Network tools<small>Lets the model fetch public web pages. Off keeps everything offline.</small></span><label class="switch"><input type="checkbox" id="s-net"><i></i></label></div>
434
474
  </div>
435
- <div class="group"><h4>Profile</h4>
436
- <div class="set"><div class="lbl">Display name</div><input type="text" id="p-name" maxlength="40"></div>
437
- <div class="set"><div class="lbl">New passcode</div><input type="password" id="p-pass" autocomplete="new-password" placeholder="Leave empty to keep it"><small>A passcode locks the app and is required with <code class="mono">--share</code>.</small></div>
438
- <div><button class="btn" id="p-save">Save profile</button></div>
439
- </div>
440
475
  <div class="group"><h4>OpenAI-compatible API</h4>
441
476
  <small class="small">Point any OpenAI SDK, editor or agent at the loaded model.</small>
442
477
  <div class="codeline"><code id="api-base"></code><button class="iconbtn" data-copy="#api-base" aria-label="Copy base URL"><svg class="ico"><use href="#i-copy"/></svg></button></div>
@@ -514,7 +549,7 @@ async function boot() {
514
549
  const me = await api('/api/me');
515
550
  if (!me.profile) return showAuth('create');
516
551
  if (!me.authed && !me.profile.accepted_terms && !me.profile.has_passcode) return showGate();
517
- if (!me.authed) return showAuth('login', me.profile.name);
552
+ if (!me.authed) { setAvatar($('#l-av'), me.profile); $('#l-av').hidden = false; return showAuth('login', me.profile.name); }
518
553
  start();
519
554
  }
520
555
  function showAuth(which, name) {
@@ -543,7 +578,8 @@ async function start() {
543
578
  $('#auth').hidden = true; $('#gate').hidden = true; $('#app').hidden = false;
544
579
  ST = await api('/api/state');
545
580
  toolsOn = !!ST.settings.tools;
546
- $('#me-name').textContent = ST.profile.name; $('#avatar').textContent = (ST.profile.name || 'A').trim()[0].toUpperCase();
581
+ $('#me-name').textContent = ST.profile.name; setAvatar($('#avatar'), ST.profile); setAvatar($('#p-av'), ST.profile);
582
+ if (ST.settings.theme) applyTheme(ST.settings.theme);
547
583
  $('#me-dev').textContent = `100% local · ${({mps: 'Apple GPU', cuda: 'NVIDIA GPU', cpu: 'CPU'})[ST.device] || ST.device}`;
548
584
  $('#mm-dev').textContent = ({mps: 'Mac (Apple GPU)', cuda: 'NVIDIA GPU', cpu: 'CPU'})[ST.device] || 'computer';
549
585
  renderModelBtn(); renderChats(); renderTools(); fillSettings(); newChat();
@@ -557,15 +593,18 @@ function renderModelBtn(loading) {
557
593
  $('#hero-note').textContent = m ? `${m.name} · ${m.params} parameters · running on this ${ST.device === 'cpu' ? 'CPU' : 'GPU'}, offline.` : 'Pick a model to start. It downloads once, then runs offline.';
558
594
  $('#send').disabled = !m && !busy;
559
595
  }
596
+ function setAvatar(el, p) {
597
+ el.innerHTML = '';
598
+ if (p && p.avatar) { const i = document.createElement('img'); i.src = p.avatar; i.alt = ''; el.appendChild(i); }
599
+ else el.textContent = ((p && p.name) || 'A').trim()[0].toUpperCase();
600
+ }
560
601
  function renderChats() {
561
- const q = $('#q').value.trim().toLowerCase();
562
- const rows = ST.chats.filter(c => !q || c.title.toLowerCase().includes(q));
602
+ const rows = ST.chats;
563
603
  $('#chats').innerHTML = rows.length ? rows.map(c => `<div class="chat-row ${CHAT && CHAT.id === c.id ? 'on' : ''}" data-id="${esc(c.id)}">
564
604
  <button class="t" data-open title="${esc(c.title)}">${c.pinned ? '📌 ' : ''}${esc(c.title)}</button>
565
605
  <span class="acts"><button data-pin title="${c.pinned ? 'Unpin' : 'Pin'}">${ico('pin')}</button><button data-rename title="Rename">${ico('edit')}</button><button data-del title="Delete">${ico('trash')}</button></span></div>`).join('')
566
- : `<div class="empty-chats">${q ? 'No matching chats.' : 'Your chats will show up here.'}</div>`;
606
+ : '<div class="empty-chats">Your chats will show up here.</div>';
567
607
  }
568
- $('#q').addEventListener('input', renderChats);
569
608
  $('#chats').addEventListener('click', async e => {
570
609
  const row = e.target.closest('.chat-row'); if (!row) return; const id = row.dataset.id; const c = ST.chats.find(x => x.id === id);
571
610
  if (e.target.closest('[data-open]')) return openChat(id);
@@ -641,7 +680,11 @@ $('#input').addEventListener('keydown', e => {
641
680
  if (e.key === 'Escape') { if (busy) stop(); else if (editAt !== null) { editAt = null; $('#hint').textContent = ''; $('#input').value = ''; autosize(); } }
642
681
  });
643
682
  $('#composer').addEventListener('submit', e => { e.preventDefault(); busy ? stop() : send(); });
644
- function renderTools() { $('#tools').classList.toggle('on', toolsOn); $('#tools').title = toolsOn ? 'Tools on: calculator, time, search chats' : 'Tools off'; }
683
+ function renderTools() {
684
+ const ok = ST ? ST.tools_ok !== false : true;
685
+ $('#tools').classList.toggle('on', toolsOn && ok); $('#tools').disabled = !ok; $('#tools').style.opacity = ok ? '' : '.45';
686
+ $('#tools').title = !ok ? 'This model is too small for reliable tool use. Try Ablate Qwen 1.5B.' : toolsOn ? 'Tools on: used only when a message needs them (maths, date and time, past chats)' : 'Tools off';
687
+ }
645
688
  $('#tools').addEventListener('click', async () => { toolsOn = !toolsOn; renderTools(); ST.settings = (await api('/api/settings', {tools: toolsOn})).settings; $('#s-tools').checked = toolsOn; });
646
689
  $('#attach').addEventListener('click', () => $('#file').click());
647
690
  $('#file').addEventListener('change', async e => {
@@ -730,7 +773,7 @@ $('#mgrid').addEventListener('click', async e => {
730
773
  if (act === 'load' || act === 'unload') {
731
774
  b.disabled = true; b.textContent = act === 'load' ? 'Loading…' : 'Unloading…'; renderModelBtn(act === 'load' ? ST.models.find(x => x.id === id).name : '');
732
775
  const j = await api('/api/models/load', {id: act === 'load' ? id : null}); ST.models = j.models; ST.loaded = j.loaded; ST.max_context = j.max_context;
733
- if (j.device) ST.device = j.device; fillSettings(); if (act === 'load') { closeAll(); $('#input').focus(); }
776
+ if (j.device) ST.device = j.device; ST.tools_ok = j.tools_ok; renderTools(); fillSettings(); if (act === 'load') { closeAll(); $('#input').focus(); }
734
777
  }
735
778
  } catch (err) { toast(err.message); }
736
779
  renderModels(); renderModelBtn();
@@ -767,16 +810,59 @@ SL.forEach(([el, k, lab, f]) => $(el).addEventListener('input', e => { $(lab).te
767
810
  $('#s-system').addEventListener('input', e => saveSettings({system: e.target.value}));
768
811
  $('#s-tools').addEventListener('change', e => { toolsOn = e.target.checked; renderTools(); saveSettings({tools: toolsOn}); });
769
812
  $('#s-net').addEventListener('change', e => saveSettings({network_tools: e.target.checked}));
813
+ $('#p-av-up').addEventListener('click', () => $('#p-av-file').click());
814
+ $('#p-av-file').addEventListener('change', async e => {
815
+ const f = e.target.files[0]; e.target.value = ''; if (!f) return;
816
+ try {
817
+ const img = await createImageBitmap(f); const c = document.createElement('canvas'); c.width = c.height = 256;
818
+ const side = Math.min(img.width, img.height);
819
+ c.getContext('2d').drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, 256, 256);
820
+ ST.profile = (await api('/api/profile', {avatar: c.toDataURL('image/webp', 0.86)})).profile;
821
+ setAvatar($('#avatar'), ST.profile); setAvatar($('#p-av'), ST.profile); toast('Photo updated');
822
+ } catch (err) { toast(err.message || 'Could not read that image'); }
823
+ });
824
+ $('#p-av-rm').addEventListener('click', async () => { ST.profile = (await api('/api/profile', {avatar: ''})).profile; setAvatar($('#avatar'), ST.profile); setAvatar($('#p-av'), ST.profile); });
770
825
  $('#p-save').addEventListener('click', async () => { const body = {name: $('#p-name').value}; if ($('#p-pass').value) body.passcode = $('#p-pass').value; ST.profile = (await api('/api/profile', body)).profile; $('#p-pass').value = ''; $('#me-name').textContent = ST.profile.name; toast('Profile saved'); });
771
826
  $('#open-settings').addEventListener('click', () => { closeAll(); $('#settings').hidden = false; });
772
827
 
773
828
  /* ---------------- misc ---------------- */
774
- function closeAll() { ['#m-models', '#m-memory', '#settings'].forEach(s => $(s).hidden = true); }
829
+ function closeAll() { ['#m-models', '#m-memory', '#settings', '#m-search'].forEach(s => $(s).hidden = true); }
830
+
831
+ /* ---------------- search (ChatGPT style) ---------------- */
832
+ let sTimer = 0, sItems = [], sOn = 0;
833
+ function hl(text, q) { const t = esc(text); if (!q) return t; const i = t.toLowerCase().indexOf(esc(q).toLowerCase()); return i < 0 ? t : t.slice(0, i) + '<mark>' + t.slice(i, i + esc(q).length) + '</mark>' + t.slice(i + esc(q).length); }
834
+ function srRow(r, q, k) { return `<button class="sr ${k === sOn ? 'on' : ''}" data-k="${k}">${ico(r.icon || 'bubble')}<span><b>${hl(r.title, q)}</b>${r.snippet ? `<small>${hl(r.snippet, q)}</small>` : ''}</span></button>`; }
835
+ function renderSearch(q, results) {
836
+ sItems = []; let html = ''; sOn = 0;
837
+ if (!q) {
838
+ sItems.push({id: null, title: 'New chat', icon: 'edit'}); html += srRow(sItems[0], '', 0);
839
+ const opened = ST.chats.filter(c => c.opened).sort((a, b) => b.opened - a.opened)[0];
840
+ if (opened) { html += '<h5>Last opened</h5>'; sItems.push(opened); html += srRow(opened, '', sItems.length - 1); }
841
+ const recent = [...ST.chats].sort((a, b) => b.updated - a.updated).filter(c => !opened || c.id !== opened.id).slice(0, 8);
842
+ if (recent.length) { html += '<h5>Recent chats</h5>'; recent.forEach(c => { sItems.push(c); html += srRow(c, '', sItems.length - 1); }); }
843
+ } else if (!results.length) { html = `<div class="search-empty">No chats match “${esc(q)}”.</div>`; }
844
+ else { html += '<h5>Results</h5>'; results.forEach(r => { sItems.push(r); html += srRow(r, q, sItems.length - 1); }); }
845
+ $('#slist').innerHTML = html;
846
+ }
847
+ function openSearch() { closeAll(); $('#m-search').hidden = false; $('#sq').value = ''; renderSearch('', []); setTimeout(() => $('#sq').focus(), 20); closeSide(); }
848
+ function pickSearch(k) { const it = sItems[k]; if (!it) return; closeAll(); if (it.id) openChat(it.id); else newChat(); }
849
+ $('#open-search').addEventListener('click', openSearch);
850
+ $('#sq').addEventListener('input', e => { const q = e.target.value.trim(); clearTimeout(sTimer); if (!q) return renderSearch('', []); sTimer = setTimeout(async () => { try { renderSearch(q, (await api('/api/search?q=' + encodeURIComponent(q))).results); } catch {} }, 160); });
851
+ $('#sq').addEventListener('keydown', e => {
852
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); sOn = Math.max(0, Math.min(sItems.length - 1, sOn + (e.key === 'ArrowDown' ? 1 : -1))); $$('#slist .sr').forEach(b => b.classList.toggle('on', +b.dataset.k === sOn)); $$('#slist .sr')[sOn]?.scrollIntoView({block: 'nearest'}); }
853
+ if (e.key === 'Enter') { e.preventDefault(); pickSearch(sOn); }
854
+ });
855
+ $('#slist').addEventListener('click', e => { const b = e.target.closest('.sr'); if (b) pickSearch(+b.dataset.k); });
856
+ function applyTheme(t) { document.documentElement.setAttribute('data-theme', t); try { localStorage.setItem('ablate-chat-theme', t); } catch {} }
775
857
  function closeSide() { $('#app').classList.remove('open'); }
776
- document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAll(); if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); newChat(); } });
777
- $('#collapse').addEventListener('click', () => $('#app').classList.add('collapsed'));
778
- $('#menu').addEventListener('click', () => { const a = $('#app'); if (a.classList.contains('collapsed')) a.classList.remove('collapsed'); else a.classList.toggle('open'); });
779
- $('#theme').addEventListener('click', () => { const t = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'; document.documentElement.setAttribute('data-theme', t); try { localStorage.setItem('ablate-chat-theme', t); } catch {} });
858
+ document.addEventListener('keydown', e => {
859
+ if (e.key === 'Escape') closeAll();
860
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !$('#app').hidden) { e.preventDefault(); openSearch(); }
861
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'o') { e.preventDefault(); newChat(); }
862
+ });
863
+ $('#collapse').addEventListener('click', () => { const a = $('#app'); if (matchMedia('(max-width:900px)').matches) a.classList.remove('open'); else a.classList.add('collapsed'); });
864
+ $('#menu').addEventListener('click', () => { const a = $('#app'); if (matchMedia('(max-width:900px)').matches) a.classList.toggle('open'); else a.classList.remove('collapsed'); });
865
+ $('#theme').addEventListener('click', async () => { const t = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'; applyTheme(t); try { ST.settings = (await api('/api/settings', {theme: t})).settings; } catch {} });
780
866
  $('#export').addEventListener('click', () => {
781
867
  if (!CHAT || !CHAT.messages.length) return toast('Nothing to export yet');
782
868
  const text = `# ${CHAT.title}\n\n` + CHAT.messages.map(m => `**${m.role === 'user' ? 'You' : 'Ablate'}:**\n\n${m.content}\n`).join('\n');
@@ -46,7 +46,7 @@ nav{display:flex;gap:16px;flex-wrap:wrap;margin:18px 0 0}
46
46
  <h2 id="privacy">Privacy Policy</h2>
47
47
  <p><strong>We collect nothing.</strong> Ablate Chat has no accounts on our servers, no analytics and no telemetry.</p>
48
48
  <ul>
49
- <li><strong>Your profile, chats, memories and settings</strong> are JSON files in <code>~/.ablate/chat</code> on your computer. Delete that folder to erase them.</li>
49
+ <li><strong>Your profile, chats, memories and settings</strong> are stored in a local SQLite database, <code>~/.ablate/chat/ablate.db</code>, on your computer. Delete that folder to erase them.</li>
50
50
  <li><strong>Prompts and answers</strong> are processed by the model on your own CPU or GPU. They are never sent to AI Anytime or anyone else.</li>
51
51
  <li><strong>Network use:</strong> the app goes online only to download a model from the HuggingFace Hub when you click Download, and to fetch a web page if you turn on network tools and the model calls them. HuggingFace's own privacy policy applies to downloads.</li>
52
52
  <li><strong>Sharing on your network:</strong> if you start the app with <code>--share</code>, other devices on your network can sign in with your passcode. Their chats are stored on your computer.</li>