ablate-llm 0.4.0__tar.gz → 0.4.1__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.
- {ablate_llm-0.4.0/src/ablate_llm.egg-info → ablate_llm-0.4.1}/PKG-INFO +5 -5
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/README.md +4 -4
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/pyproject.toml +1 -1
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/__init__.py +1 -1
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/chat/server.py +46 -22
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/chat/static/index.html +117 -31
- ablate_llm-0.4.1/src/ablate/chat/static/logo-dark.png +0 -0
- ablate_llm-0.4.1/src/ablate/chat/static/logo-light.png +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/chat/static/terms.html +1 -1
- ablate_llm-0.4.1/src/ablate/chat/store.py +314 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/chat/tools.py +20 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/cli.py +6 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1/src/ablate_llm.egg-info}/PKG-INFO +5 -5
- ablate_llm-0.4.0/src/ablate/chat/store.py +0 -193
- ablate_llm-0.4.0/src/ablate/ui/static/logo-dark.png +0 -0
- ablate_llm-0.4.0/src/ablate/ui/static/logo-light.png +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/LICENSE +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/NOTICE +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/setup.cfg +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/_data/__init__.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/_data/harmful.txt +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/_data/harmless.txt +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/chat/__init__.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/chat/static/mark.png +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/config.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/data.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/evaluate.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/extract.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/harness.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/hooks.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/hub.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/judges.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/model.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/optimize.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/pipeline.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/subspace.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/ui/__init__.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/ui/server.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/ui/static/architecture.svg +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/ui/static/index.html +0 -0
- {ablate_llm-0.4.0/src/ablate/chat → ablate_llm-0.4.1/src/ablate/ui}/static/logo-dark.png +0 -0
- {ablate_llm-0.4.0/src/ablate/chat → ablate_llm-0.4.1/src/ablate/ui}/static/logo-light.png +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/utils.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate/weights.py +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate_llm.egg-info/SOURCES.txt +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate_llm.egg-info/dependency_links.txt +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate_llm.egg-info/entry_points.txt +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/src/ablate_llm.egg-info/requires.txt +0 -0
- {ablate_llm-0.4.0 → ablate_llm-0.4.1}/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.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
121
|
-
|
|
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
|
|
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
|
|
78
|
-
|
|
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
|
|
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.
|
|
7
|
+
version = "0.4.1"
|
|
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"
|
|
@@ -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%.
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
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
|
|
305
|
+
if offer and tools.looks_like_call(head) and len(head) < 12:
|
|
298
306
|
return
|
|
299
|
-
mode = "tool" if
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
69
|
-
.side-top img{height:
|
|
70
|
-
.
|
|
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:
|
|
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
|
|
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?
|
|
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="
|
|
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="
|
|
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
|
|
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')
|
|
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
|
|
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
|
-
:
|
|
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() {
|
|
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 => {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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');
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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>
|