alive-ai 0.1.12 → 0.1.14
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.
- package/README.md +6 -0
- package/brain/default_mode.py +2 -1
- package/cli/check_webui_static.js +45 -0
- package/core/message_handler.py +17 -6
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/webui/app.py +132 -22
- package/webui/bridge.py +14 -4
- package/webui/persistence.py +174 -0
- package/webui/static/icon.svg +7 -0
- package/webui/static/index.html +147 -75
- package/webui/static/manifest.json +18 -0
package/README.md
CHANGED
|
@@ -277,6 +277,12 @@ The real WebUI streams local runtime state over Server-Sent Events and shows:
|
|
|
277
277
|
- attachment, circadian rhythm, sleepiness, body memory, dreams, curiosity, and conflicts,
|
|
278
278
|
- runtime health through local endpoints.
|
|
279
279
|
|
|
280
|
+
The WebUI hydrates from durable runtime stores instead of only the current browser session. Chat rows are journaled per active user under `data/users/<user>/webui_chat.jsonl`, with fallback to episodic conversation history. `/state` and the SSE stream now use the same composed snapshot: visible chat, runtime state, soul state, aliveness state, current thoughts, memory counters, and the active dashboard user.
|
|
281
|
+
|
|
282
|
+
Settings edits validate JSON before saving and write atomically, so a bad edit cannot corrupt the existing config file. The Settings tab also protects unsaved edits while switching tabs.
|
|
283
|
+
|
|
284
|
+
The WebUI script is intentionally shipped as a single static file because the npm package has to run locally without a frontend build step. `npm run smoke` compiles the Python modules and checks the CLI; release validation also parses the embedded dashboard script and verifies required dashboard hooks so tab navigation, chat, settings, and thought rendering cannot be broken by a syntax error.
|
|
285
|
+
|
|
280
286
|
GitHub Pages cannot run the Python/FastAPI backend, so the public page includes a static export of the actual WebUI with mocked state:
|
|
281
287
|
|
|
282
288
|
```text
|
package/brain/default_mode.py
CHANGED
|
@@ -251,7 +251,8 @@ class DefaultModeProcessor:
|
|
|
251
251
|
if data_path:
|
|
252
252
|
self.data_path = data_path
|
|
253
253
|
else:
|
|
254
|
-
|
|
254
|
+
from core.paths import data_dir
|
|
255
|
+
self.data_path = data_dir()
|
|
255
256
|
|
|
256
257
|
self.data_path.mkdir(parents=True, exist_ok=True)
|
|
257
258
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
const root = path.resolve(__dirname, "..");
|
|
6
|
+
const htmlPath = path.join(root, "webui", "static", "index.html");
|
|
7
|
+
const manifestPath = path.join(root, "webui", "static", "manifest.json");
|
|
8
|
+
|
|
9
|
+
const html = fs.readFileSync(htmlPath, "utf8");
|
|
10
|
+
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
|
11
|
+
let count = 0;
|
|
12
|
+
for (const match of html.matchAll(scriptRegex)) {
|
|
13
|
+
count += 1;
|
|
14
|
+
try {
|
|
15
|
+
new Function(match[1]);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(`Invalid inline script #${count} in ${path.relative(root, htmlPath)}:`);
|
|
18
|
+
console.error(error.message);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (count === 0) {
|
|
24
|
+
console.error(`No inline scripts found in ${path.relative(root, htmlPath)}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
29
|
+
for (const page of ["home", "chat", "settings"]) {
|
|
30
|
+
if (!html.includes(`data-page="${page}"`)) {
|
|
31
|
+
console.error(`Missing bottom-nav data-page="${page}" hook.`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
if (!html.includes(`id="page-${page}"`)) {
|
|
35
|
+
console.error(`Missing page container #page-${page}.`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const required of ["/static/manifest.json", "/static/icon.svg"]) {
|
|
40
|
+
if (!html.includes(required)) {
|
|
41
|
+
console.error(`Missing static asset reference: ${required}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
console.log(`WebUI static check passed (${count} inline script parsed).`);
|
package/core/message_handler.py
CHANGED
|
@@ -264,11 +264,11 @@ def _get_or_create_user_memory(self, user_id: str):
|
|
|
264
264
|
if cache_key in _user_memories:
|
|
265
265
|
return _user_memories[cache_key]
|
|
266
266
|
|
|
267
|
-
# Create new memory instance for this user using
|
|
267
|
+
# Create new memory instance for this user using the canonical per-user path.
|
|
268
268
|
from brain.memory import Memory
|
|
269
|
+
from core.user_manager import UserManager
|
|
269
270
|
|
|
270
|
-
|
|
271
|
-
instance_data_path = self.base / "data"
|
|
271
|
+
instance_data_path = UserManager().get_user_paths(user_id)["base"]
|
|
272
272
|
|
|
273
273
|
memory = Memory(
|
|
274
274
|
nervous=self.nervous,
|
|
@@ -462,6 +462,7 @@ async def _process_single_message(self, data: dict):
|
|
|
462
462
|
if self._subconscious: self._subconscious.register_interaction()
|
|
463
463
|
if chat_id: self._default_chat_id = chat_id
|
|
464
464
|
text = data.get("text", "")
|
|
465
|
+
message_id = data.get("message_id")
|
|
465
466
|
|
|
466
467
|
circadian_interaction = {}
|
|
467
468
|
if CIRCADIAN_AVAILABLE:
|
|
@@ -786,7 +787,7 @@ async def _process_single_message(self, data: dict):
|
|
|
786
787
|
# Track if we asked a question (for follow-ups)
|
|
787
788
|
_follow_up.record_message_sent(response)
|
|
788
789
|
|
|
789
|
-
await _send_response(self, response, emotion, chat_id, text, user_id)
|
|
790
|
+
await _send_response(self, response, emotion, chat_id, text, user_id, message_id=message_id)
|
|
790
791
|
if self._subconscious: _feed_learning(self._subconscious, text)
|
|
791
792
|
|
|
792
793
|
# Actually send the media (we already decided what to send)
|
|
@@ -1094,7 +1095,7 @@ IMPORTANT: You are sending this media ALONG with your message. Reference it natu
|
|
|
1094
1095
|
return fallback_response(emotion, msg)
|
|
1095
1096
|
|
|
1096
1097
|
|
|
1097
|
-
async def _send_response(self, response, emotion, chat_id, text, user_id="default"):
|
|
1098
|
+
async def _send_response(self, response, emotion, chat_id, text, user_id="default", message_id=None):
|
|
1098
1099
|
mood = emotion.get("mood", "neutral")
|
|
1099
1100
|
|
|
1100
1101
|
# Process any action tags in the response (pass instance config path)
|
|
@@ -1123,9 +1124,19 @@ async def _send_response(self, response, emotion, chat_id, text, user_id="defaul
|
|
|
1123
1124
|
"chat_id": chat_id,
|
|
1124
1125
|
"fallback_text": response,
|
|
1125
1126
|
"mood": mood,
|
|
1127
|
+
"user_id": user_id,
|
|
1128
|
+
"reply_to_message_id": message_id,
|
|
1129
|
+
"source": "runtime",
|
|
1126
1130
|
})
|
|
1127
1131
|
return
|
|
1128
|
-
await self.nervous.emit("send_text", {
|
|
1132
|
+
await self.nervous.emit("send_text", {
|
|
1133
|
+
"text": response,
|
|
1134
|
+
"mood": mood,
|
|
1135
|
+
"chat_id": chat_id,
|
|
1136
|
+
"user_id": user_id,
|
|
1137
|
+
"reply_to_message_id": message_id,
|
|
1138
|
+
"source": "runtime",
|
|
1139
|
+
})
|
|
1129
1140
|
|
|
1130
1141
|
|
|
1131
1142
|
def _process_self_authorship_actions(response: str, user_id: str = "default", self_path: Path = None) -> tuple:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alive-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Local-first emotional AI runtime with memory, impulses, and a live dashboard.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://vindepemarte.github.io/alive-ai/",
|
|
@@ -57,6 +57,6 @@
|
|
|
57
57
|
"node": ">=18"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
|
-
"smoke": "node cli/index.js --help && python3 -m compileall -q alive_ai brain core heart input output skills webui"
|
|
60
|
+
"smoke": "node cli/index.js --help && node cli/check_webui_static.js && python3 -m compileall -q alive_ai brain core heart input output skills webui"
|
|
61
61
|
}
|
|
62
62
|
}
|
package/pyproject.toml
CHANGED
package/webui/app.py
CHANGED
|
@@ -13,6 +13,12 @@ from fastapi import FastAPI, Request, BackgroundTasks
|
|
|
13
13
|
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse, JSONResponse
|
|
14
14
|
from fastapi.staticfiles import StaticFiles
|
|
15
15
|
from core.paths import data_dir, media_dir
|
|
16
|
+
from .persistence import (
|
|
17
|
+
append_chat_message,
|
|
18
|
+
load_chat_messages,
|
|
19
|
+
new_message_id,
|
|
20
|
+
resolve_active_user_id,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
app = FastAPI(title="Alive-AI Dashboard")
|
|
@@ -245,6 +251,75 @@ aliveness_state = {
|
|
|
245
251
|
}
|
|
246
252
|
|
|
247
253
|
|
|
254
|
+
def _active_user_id(explicit=None) -> str:
|
|
255
|
+
return resolve_active_user_id(explicit, self_ref=_self_ref, dashboard_state=alive_ai_state)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _runtime_state_dict() -> dict:
|
|
259
|
+
runtime_state = getattr(_self_ref, "state", None)
|
|
260
|
+
if runtime_state and hasattr(runtime_state, "to_dict"):
|
|
261
|
+
try:
|
|
262
|
+
return runtime_state.to_dict()
|
|
263
|
+
except Exception:
|
|
264
|
+
return {}
|
|
265
|
+
return {}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _runtime_chat_ready() -> bool:
|
|
269
|
+
nervous = getattr(_self_ref, "nervous", None)
|
|
270
|
+
listeners = getattr(nervous, "listeners", {}) if nervous else {}
|
|
271
|
+
# Bridge registers one listener; the runtime handler is attached during Self.start().
|
|
272
|
+
return len(listeners.get("message_received", [])) > 1
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _subconscious_thoughts(limit: int = 10) -> list:
|
|
276
|
+
thoughts = []
|
|
277
|
+
sub = getattr(_self_ref, "_subconscious", None)
|
|
278
|
+
wm = getattr(sub, "working_memory", None)
|
|
279
|
+
if wm and hasattr(wm, "get_recent_thoughts"):
|
|
280
|
+
try:
|
|
281
|
+
for thought in wm.get_recent_thoughts(limit):
|
|
282
|
+
thoughts.append({
|
|
283
|
+
"thought": getattr(thought, "content", ""),
|
|
284
|
+
"type": getattr(thought, "type", "reflection"),
|
|
285
|
+
"emotion": getattr(thought, "emotion", {}) or {},
|
|
286
|
+
"time": _format_time(getattr(thought, "created_at", None)),
|
|
287
|
+
})
|
|
288
|
+
except Exception:
|
|
289
|
+
thoughts = []
|
|
290
|
+
if thoughts:
|
|
291
|
+
return thoughts[-limit:]
|
|
292
|
+
return alive_ai_state.get("recent_thoughts", [])[-limit:]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _format_time(value) -> str:
|
|
296
|
+
if not value:
|
|
297
|
+
return datetime.now().strftime("%H:%M:%S")
|
|
298
|
+
try:
|
|
299
|
+
if isinstance(value, datetime):
|
|
300
|
+
return value.strftime("%H:%M:%S")
|
|
301
|
+
return datetime.fromisoformat(str(value)).strftime("%H:%M:%S")
|
|
302
|
+
except Exception:
|
|
303
|
+
text = str(value)
|
|
304
|
+
return text[11:19] if len(text) >= 19 else text
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def build_snapshot(user_id: str = None) -> dict:
|
|
308
|
+
"""Compose the dashboard state from live and durable runtime stores."""
|
|
309
|
+
active_user = _active_user_id(user_id)
|
|
310
|
+
snapshot = dict(alive_ai_state)
|
|
311
|
+
snapshot["active_user"] = active_user
|
|
312
|
+
snapshot["runtime"] = _runtime_state_dict()
|
|
313
|
+
snapshot["soul"] = soul_state
|
|
314
|
+
snapshot["aliveness"] = aliveness_state
|
|
315
|
+
snapshot["conversation"] = load_chat_messages(active_user)
|
|
316
|
+
thoughts = _subconscious_thoughts()
|
|
317
|
+
snapshot["recent_thoughts"] = thoughts
|
|
318
|
+
snapshot["current_thought"] = thoughts[-1]["thought"] if thoughts else alive_ai_state.get("current_thought")
|
|
319
|
+
snapshot["updated_at"] = datetime.now().isoformat()
|
|
320
|
+
return snapshot
|
|
321
|
+
|
|
322
|
+
|
|
248
323
|
def update_state(data: dict):
|
|
249
324
|
"""Called by nervous system to update state"""
|
|
250
325
|
global alive_ai_state
|
|
@@ -255,15 +330,24 @@ def update_state(data: dict):
|
|
|
255
330
|
client.set()
|
|
256
331
|
|
|
257
332
|
|
|
258
|
-
def add_conversation(role: str, content: str
|
|
333
|
+
def add_conversation(role: str, content: str, message_id: str = None,
|
|
334
|
+
status: str = "sent", user_id: str = None,
|
|
335
|
+
source: str = "runtime"):
|
|
259
336
|
"""Add a message to conversation history"""
|
|
337
|
+
if message_id and any(m.get("message_id") == message_id for m in alive_ai_state["conversation"]):
|
|
338
|
+
return
|
|
260
339
|
alive_ai_state["conversation"].append({
|
|
340
|
+
"message_id": message_id or new_message_id(role),
|
|
261
341
|
"role": role,
|
|
262
342
|
"content": content,
|
|
263
|
-
"time": datetime.now().strftime("%H:%M:%S")
|
|
343
|
+
"time": datetime.now().strftime("%H:%M:%S"),
|
|
344
|
+
"status": status,
|
|
345
|
+
"source": source,
|
|
264
346
|
})
|
|
265
347
|
# Keep last 20 messages
|
|
266
348
|
alive_ai_state["conversation"] = alive_ai_state["conversation"][-20:]
|
|
349
|
+
if user_id:
|
|
350
|
+
alive_ai_state["active_user"] = user_id
|
|
267
351
|
if role == "user":
|
|
268
352
|
alive_ai_state["last_user_message"] = content
|
|
269
353
|
else:
|
|
@@ -316,7 +400,7 @@ async def event_generator(request: Request):
|
|
|
316
400
|
|
|
317
401
|
try:
|
|
318
402
|
# Send initial state
|
|
319
|
-
yield f"event: state\ndata: {json.dumps(
|
|
403
|
+
yield f"event: state\ndata: {json.dumps(build_snapshot())}\n\n"
|
|
320
404
|
|
|
321
405
|
while True:
|
|
322
406
|
if await request.is_disconnected():
|
|
@@ -331,7 +415,7 @@ async def event_generator(request: Request):
|
|
|
331
415
|
continue
|
|
332
416
|
|
|
333
417
|
# Send updated state
|
|
334
|
-
yield f"event: state\ndata: {json.dumps(
|
|
418
|
+
yield f"event: state\ndata: {json.dumps(build_snapshot())}\n\n"
|
|
335
419
|
except asyncio.CancelledError:
|
|
336
420
|
pass # Client disconnected normally
|
|
337
421
|
except Exception as e:
|
|
@@ -348,6 +432,13 @@ async def dashboard():
|
|
|
348
432
|
return HTMLResponse(content=html_path.read_text())
|
|
349
433
|
|
|
350
434
|
|
|
435
|
+
@app.get("/favicon.ico")
|
|
436
|
+
async def favicon():
|
|
437
|
+
"""Serve the dashboard icon for browsers that still request /favicon.ico."""
|
|
438
|
+
icon_path = Path(__file__).parent / "static" / "icon.svg"
|
|
439
|
+
return FileResponse(icon_path, media_type="image/svg+xml")
|
|
440
|
+
|
|
441
|
+
|
|
351
442
|
@app.get("/events")
|
|
352
443
|
async def sse_events(request: Request):
|
|
353
444
|
"""SSE endpoint for real-time updates"""
|
|
@@ -365,7 +456,7 @@ async def sse_events(request: Request):
|
|
|
365
456
|
@app.get("/state")
|
|
366
457
|
async def get_state():
|
|
367
458
|
"""Get current state (for polling fallback)"""
|
|
368
|
-
return
|
|
459
|
+
return build_snapshot()
|
|
369
460
|
|
|
370
461
|
|
|
371
462
|
@app.get("/avatar")
|
|
@@ -449,9 +540,10 @@ async def get_memory_status():
|
|
|
449
540
|
@app.get("/thoughts")
|
|
450
541
|
async def get_thoughts():
|
|
451
542
|
"""Get recent thoughts from subconscious"""
|
|
543
|
+
thoughts = _subconscious_thoughts()
|
|
452
544
|
return {
|
|
453
|
-
"current_thought": alive_ai_state.get("current_thought"),
|
|
454
|
-
"recent_thoughts":
|
|
545
|
+
"current_thought": thoughts[-1]["thought"] if thoughts else alive_ai_state.get("current_thought"),
|
|
546
|
+
"recent_thoughts": thoughts
|
|
455
547
|
}
|
|
456
548
|
|
|
457
549
|
|
|
@@ -719,7 +811,7 @@ async def get_memory_state():
|
|
|
719
811
|
# Try to get fresh data from emotional memory system
|
|
720
812
|
try:
|
|
721
813
|
from brain.emotional_memory import get_emotional_memory_system
|
|
722
|
-
system = get_emotional_memory_system()
|
|
814
|
+
system = get_emotional_memory_system(_active_user_id())
|
|
723
815
|
stats = system.get_stats()
|
|
724
816
|
recent_high = system.get_recent_high_emotion(hours=24, limit=1)
|
|
725
817
|
|
|
@@ -959,22 +1051,35 @@ async def get_new_aliveness():
|
|
|
959
1051
|
async def chat_endpoint(request: Request, background_tasks: BackgroundTasks):
|
|
960
1052
|
data = await request.json()
|
|
961
1053
|
text = data.get("text", "").strip()
|
|
962
|
-
if not text or not _self_ref:
|
|
1054
|
+
if not text or not _self_ref or not _runtime_chat_ready():
|
|
963
1055
|
return JSONResponse({"status": "error", "message": "No text or AI not ready"}, 400)
|
|
964
|
-
|
|
965
|
-
|
|
1056
|
+
user_id = _active_user_id(data.get("user_id"))
|
|
1057
|
+
message_id = data.get("message_id") or new_message_id("webui_user")
|
|
1058
|
+
append_chat_message(user_id, "user", text, message_id=message_id, status="pending", source="webui")
|
|
1059
|
+
add_conversation("user", text, message_id=message_id, status="pending", user_id=user_id, source="webui")
|
|
966
1060
|
update_state({})
|
|
967
|
-
|
|
1061
|
+
|
|
968
1062
|
async def _send():
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1063
|
+
try:
|
|
1064
|
+
await _self_ref.nervous.emit("message_received", {
|
|
1065
|
+
"message_id": message_id,
|
|
1066
|
+
"user_id": user_id,
|
|
1067
|
+
"webui_user_id": user_id,
|
|
1068
|
+
"text": text,
|
|
1069
|
+
"chat_id": "webui",
|
|
1070
|
+
"source": "webui"
|
|
1071
|
+
})
|
|
1072
|
+
except Exception as e:
|
|
1073
|
+
append_chat_message(
|
|
1074
|
+
user_id,
|
|
1075
|
+
"alive_ai",
|
|
1076
|
+
f"Something went wrong while processing that message: {e}",
|
|
1077
|
+
status="error",
|
|
1078
|
+
source="webui",
|
|
1079
|
+
)
|
|
1080
|
+
update_state({"thinking": False})
|
|
976
1081
|
background_tasks.add_task(_send)
|
|
977
|
-
return JSONResponse({"status": "sent"})
|
|
1082
|
+
return JSONResponse({"status": "sent", "message_id": message_id, "user_id": user_id})
|
|
978
1083
|
|
|
979
1084
|
|
|
980
1085
|
@app.get("/api/settings")
|
|
@@ -1006,13 +1111,18 @@ async def save_settings(request: Request):
|
|
|
1006
1111
|
if fname not in allowed:
|
|
1007
1112
|
return JSONResponse({"status": "error", "message": "Invalid file"}, 400)
|
|
1008
1113
|
config_dir = Path(os.environ.get("ALIVE_AI_ROOT", ".")) / "config"
|
|
1114
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
1009
1115
|
p = config_dir / fname
|
|
1010
1116
|
content = data.get("content")
|
|
1011
1117
|
try:
|
|
1012
1118
|
if fname.endswith(".json"):
|
|
1013
|
-
|
|
1119
|
+
text = json.dumps(content, indent=2, ensure_ascii=False) + "\n"
|
|
1120
|
+
json.loads(text)
|
|
1014
1121
|
else:
|
|
1015
|
-
|
|
1122
|
+
text = str(content or "")
|
|
1123
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
1124
|
+
tmp.write_text(text)
|
|
1125
|
+
tmp.replace(p)
|
|
1016
1126
|
return {"status": "saved"}
|
|
1017
1127
|
except Exception as e:
|
|
1018
1128
|
return JSONResponse({"status": "error", "message": str(e)}, 500)
|
package/webui/bridge.py
CHANGED
|
@@ -10,6 +10,7 @@ from .app import (
|
|
|
10
10
|
update_interoceptive_state, update_idle_state, update_bids_state,
|
|
11
11
|
update_memory_state, update_inconsistency_state
|
|
12
12
|
)
|
|
13
|
+
from .persistence import append_chat_message, new_message_id, resolve_active_user_id
|
|
13
14
|
|
|
14
15
|
_webui_server = None
|
|
15
16
|
|
|
@@ -46,16 +47,25 @@ def init_bridge(nervous, ai=None):
|
|
|
46
47
|
|
|
47
48
|
async def on_message_sent(data):
|
|
48
49
|
"""Track outgoing messages"""
|
|
49
|
-
text = data.get("text", "")
|
|
50
|
-
|
|
50
|
+
text = data.get("text") or data.get("fallback_text", "")
|
|
51
|
+
user_id = resolve_active_user_id(data.get("user_id"), dashboard_state=alive_ai_state)
|
|
52
|
+
message_id = data.get("message_id") or new_message_id("alive_ai")
|
|
53
|
+
append_chat_message(user_id, "alive_ai", text, message_id=message_id,
|
|
54
|
+
status="sent", source=data.get("source", "runtime"))
|
|
55
|
+
add_conversation("alive_ai", text, message_id=message_id, user_id=user_id,
|
|
56
|
+
source=data.get("source", "runtime"))
|
|
51
57
|
alive_ai_state["stats"]["messages"] = alive_ai_state["stats"].get("messages", 0) + 1
|
|
52
58
|
update_state({})
|
|
53
59
|
|
|
54
60
|
async def on_message_received(data):
|
|
55
61
|
"""Track incoming messages"""
|
|
56
62
|
text = data.get("text", "")
|
|
57
|
-
user_id = data.get("
|
|
58
|
-
|
|
63
|
+
user_id = resolve_active_user_id(data.get("webui_user_id") or data.get("user_id"), dashboard_state=alive_ai_state)
|
|
64
|
+
message_id = data.get("message_id") or new_message_id("user")
|
|
65
|
+
append_chat_message(user_id, "user", text, message_id=message_id,
|
|
66
|
+
status="sent", source=data.get("source", "runtime"))
|
|
67
|
+
add_conversation("user", text, message_id=message_id, user_id=user_id,
|
|
68
|
+
source=data.get("source", "runtime"))
|
|
59
69
|
# Track active user
|
|
60
70
|
alive_ai_state["active_user"] = user_id
|
|
61
71
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Durable WebUI projection helpers.
|
|
2
|
+
|
|
3
|
+
The runtime has several durable stores. This module gives the dashboard one
|
|
4
|
+
small journal for visible chat rows and a safe fallback into episodic memory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from core.paths import data_dir
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_SAFE_ID = re.compile(r"[^A-Za-z0-9_.@-]+")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_user_id(user_id: Any) -> str:
|
|
22
|
+
raw = str(user_id or "").strip()
|
|
23
|
+
if not raw:
|
|
24
|
+
raw = "webui"
|
|
25
|
+
safe = _SAFE_ID.sub("_", raw).strip("._-")
|
|
26
|
+
return safe or "webui"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_active_user_id(explicit: Any = None, self_ref: Any = None,
|
|
30
|
+
dashboard_state: Optional[Dict[str, Any]] = None) -> str:
|
|
31
|
+
if explicit:
|
|
32
|
+
return normalize_user_id(explicit)
|
|
33
|
+
|
|
34
|
+
dashboard_state = dashboard_state or {}
|
|
35
|
+
if dashboard_state.get("active_user"):
|
|
36
|
+
return normalize_user_id(dashboard_state["active_user"])
|
|
37
|
+
|
|
38
|
+
runtime_state = getattr(self_ref, "state", None)
|
|
39
|
+
if runtime_state and getattr(runtime_state, "user_id", None):
|
|
40
|
+
return normalize_user_id(runtime_state.user_id)
|
|
41
|
+
|
|
42
|
+
owner = os.environ.get("TELEGRAM_OWNER_ID", "")
|
|
43
|
+
if owner:
|
|
44
|
+
return normalize_user_id(owner)
|
|
45
|
+
|
|
46
|
+
return "webui"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def user_base(user_id: str) -> Path:
|
|
50
|
+
path = data_dir() / "users" / normalize_user_id(user_id)
|
|
51
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def chat_journal_path(user_id: str) -> Path:
|
|
56
|
+
return user_base(user_id) / "webui_chat.jsonl"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def new_message_id(prefix: str = "msg") -> str:
|
|
60
|
+
return f"{prefix}_{uuid.uuid4().hex[:16]}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_jsonl(path: Path) -> List[Dict[str, Any]]:
|
|
64
|
+
rows: List[Dict[str, Any]] = []
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return rows
|
|
67
|
+
with path.open() as fh:
|
|
68
|
+
for line in fh:
|
|
69
|
+
try:
|
|
70
|
+
row = json.loads(line)
|
|
71
|
+
if isinstance(row, dict):
|
|
72
|
+
rows.append(row)
|
|
73
|
+
except Exception:
|
|
74
|
+
continue
|
|
75
|
+
return rows
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def append_chat_message(user_id: str, role: str, content: str,
|
|
79
|
+
message_id: Optional[str] = None,
|
|
80
|
+
status: str = "sent", source: str = "runtime",
|
|
81
|
+
metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
82
|
+
message_id = message_id or new_message_id(role)
|
|
83
|
+
entry = {
|
|
84
|
+
"message_id": message_id,
|
|
85
|
+
"role": role,
|
|
86
|
+
"content": str(content or ""),
|
|
87
|
+
"status": status,
|
|
88
|
+
"source": source,
|
|
89
|
+
"timestamp": datetime.now().isoformat(),
|
|
90
|
+
}
|
|
91
|
+
if metadata:
|
|
92
|
+
entry["metadata"] = metadata
|
|
93
|
+
|
|
94
|
+
path = chat_journal_path(user_id)
|
|
95
|
+
existing = _read_jsonl(path)
|
|
96
|
+
for idx, row in enumerate(existing):
|
|
97
|
+
if row.get("message_id") == message_id:
|
|
98
|
+
merged = {**row, **{k: v for k, v in entry.items() if v not in (None, "")}}
|
|
99
|
+
existing[idx] = merged
|
|
100
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
101
|
+
with tmp.open("w") as fh:
|
|
102
|
+
for item in existing:
|
|
103
|
+
fh.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
104
|
+
tmp.replace(path)
|
|
105
|
+
return entry
|
|
106
|
+
with path.open("a") as fh:
|
|
107
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
108
|
+
return entry
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _format_entry(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
112
|
+
timestamp = row.get("timestamp") or row.get("created_at") or ""
|
|
113
|
+
time_label = ""
|
|
114
|
+
if timestamp:
|
|
115
|
+
try:
|
|
116
|
+
time_label = datetime.fromisoformat(timestamp).strftime("%H:%M:%S")
|
|
117
|
+
except Exception:
|
|
118
|
+
time_label = str(timestamp)[11:19] if len(str(timestamp)) >= 19 else ""
|
|
119
|
+
return {
|
|
120
|
+
"message_id": row.get("message_id") or new_message_id("legacy"),
|
|
121
|
+
"role": row.get("role", "assistant"),
|
|
122
|
+
"content": row.get("content", ""),
|
|
123
|
+
"time": time_label,
|
|
124
|
+
"timestamp": timestamp,
|
|
125
|
+
"status": row.get("status", "sent"),
|
|
126
|
+
"source": row.get("source", "runtime"),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_journal(user_id: str) -> List[Dict[str, Any]]:
|
|
131
|
+
return [_format_entry(row) for row in _read_jsonl(chat_journal_path(user_id))]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_episodic_fallback(user_id: str, limit_turns: int) -> List[Dict[str, Any]]:
|
|
135
|
+
base = user_base(user_id) / "conversations"
|
|
136
|
+
legacy = data_dir() / "conversations"
|
|
137
|
+
conv_dir = base if list(base.glob("*.jsonl")) else legacy
|
|
138
|
+
if not conv_dir.exists():
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
turns: List[Dict[str, Any]] = []
|
|
142
|
+
for file in sorted(conv_dir.glob("*.jsonl"), reverse=True):
|
|
143
|
+
file_rows = _read_jsonl(file)
|
|
144
|
+
turns.extend(reversed(file_rows))
|
|
145
|
+
if len(turns) >= limit_turns:
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
messages: List[Dict[str, Any]] = []
|
|
149
|
+
for row in reversed(turns[:limit_turns]):
|
|
150
|
+
ts = row.get("timestamp", "")
|
|
151
|
+
if row.get("user"):
|
|
152
|
+
messages.append(_format_entry({
|
|
153
|
+
"message_id": f"legacy_user_{len(messages)}_{ts}",
|
|
154
|
+
"role": "user",
|
|
155
|
+
"content": row.get("user", ""),
|
|
156
|
+
"timestamp": ts,
|
|
157
|
+
"source": "episodic",
|
|
158
|
+
}))
|
|
159
|
+
if row.get("ai"):
|
|
160
|
+
messages.append(_format_entry({
|
|
161
|
+
"message_id": f"legacy_ai_{len(messages)}_{ts}",
|
|
162
|
+
"role": "alive_ai",
|
|
163
|
+
"content": row.get("ai", ""),
|
|
164
|
+
"timestamp": ts,
|
|
165
|
+
"source": "episodic",
|
|
166
|
+
}))
|
|
167
|
+
return messages
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def load_chat_messages(user_id: str, limit: int = 60) -> List[Dict[str, Any]]:
|
|
171
|
+
messages = _load_journal(user_id)
|
|
172
|
+
if not messages:
|
|
173
|
+
messages = _load_episodic_fallback(user_id, max(1, limit // 2))
|
|
174
|
+
return messages[-limit:]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
|
2
|
+
<rect width="128" height="128" rx="28" fill="#10121f"/>
|
|
3
|
+
<circle cx="64" cy="64" r="42" fill="none" stroke="#00d4ff" stroke-width="8"/>
|
|
4
|
+
<path d="M39 72c9 14 41 14 50 0" fill="none" stroke="#ff4d9d" stroke-width="8" stroke-linecap="round"/>
|
|
5
|
+
<circle cx="49" cy="53" r="7" fill="#f7f7fb"/>
|
|
6
|
+
<circle cx="79" cy="53" r="7" fill="#f7f7fb"/>
|
|
7
|
+
</svg>
|
package/webui/static/index.html
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
6
6
|
<meta name="theme-color" content="#0f0f1a">
|
|
7
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
7
8
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
9
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
9
10
|
<title>Alive-AI</title>
|
|
11
|
+
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
|
10
12
|
<link rel="manifest" href="/static/manifest.json">
|
|
11
13
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
12
14
|
<style>
|
|
@@ -628,11 +630,11 @@
|
|
|
628
630
|
right: 0;
|
|
629
631
|
background: var(--bg-secondary);
|
|
630
632
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
|
631
|
-
padding: 8px
|
|
633
|
+
padding: 8px 12px;
|
|
632
634
|
padding-bottom: calc(8px + var(--safe-bottom));
|
|
633
|
-
display:
|
|
634
|
-
|
|
635
|
-
gap:
|
|
635
|
+
display: grid;
|
|
636
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
637
|
+
gap: 8px;
|
|
636
638
|
z-index: 100;
|
|
637
639
|
}
|
|
638
640
|
|
|
@@ -645,6 +647,11 @@
|
|
|
645
647
|
border-radius: 12px;
|
|
646
648
|
transition: all 0.2s;
|
|
647
649
|
cursor: pointer;
|
|
650
|
+
border: 0;
|
|
651
|
+
background: transparent;
|
|
652
|
+
color: var(--text-secondary);
|
|
653
|
+
font: inherit;
|
|
654
|
+
min-width: 0;
|
|
648
655
|
}
|
|
649
656
|
|
|
650
657
|
.nav-item.active {
|
|
@@ -1071,7 +1078,8 @@
|
|
|
1071
1078
|
padding-bottom: 0;
|
|
1072
1079
|
display: flex;
|
|
1073
1080
|
flex-direction: column;
|
|
1074
|
-
height: calc(
|
|
1081
|
+
height: calc(100dvh - 75px - var(--safe-top) - var(--safe-bottom) - 75px);
|
|
1082
|
+
min-height: 0;
|
|
1075
1083
|
}
|
|
1076
1084
|
|
|
1077
1085
|
.page-section {
|
|
@@ -1084,11 +1092,13 @@
|
|
|
1084
1092
|
display: flex;
|
|
1085
1093
|
flex-direction: column;
|
|
1086
1094
|
height: 100%;
|
|
1095
|
+
min-height: 0;
|
|
1087
1096
|
position: relative;
|
|
1088
1097
|
}
|
|
1089
1098
|
|
|
1090
1099
|
.chat-messages {
|
|
1091
1100
|
flex: 1;
|
|
1101
|
+
min-height: 0;
|
|
1092
1102
|
overflow-y: auto;
|
|
1093
1103
|
padding: 10px 0;
|
|
1094
1104
|
display: flex;
|
|
@@ -1864,7 +1874,7 @@
|
|
|
1864
1874
|
</div>
|
|
1865
1875
|
</div>
|
|
1866
1876
|
<div class="chat-input-container" style="display:flex; gap:10px; padding:10px 0; background:var(--bg-primary); border-top:1px solid rgba(255,255,255,0.05); align-items: flex-end;">
|
|
1867
|
-
<textarea id="chat-input" rows="1" placeholder="Type a message..." style="flex:1; background:var(--bg-card); border:1px solid rgba(255,255,255,0.1); border-radius:20px; padding:10px 16px; color:#fff; font-size:0.9rem; resize:none; font-family:inherit; outline:none; transition:border-color 0.2s;
|
|
1877
|
+
<textarea id="chat-input" rows="1" placeholder="Type a message..." style="flex:1; background:var(--bg-card); border:1px solid rgba(255,255,255,0.1); border-radius:20px; padding:10px 16px; color:#fff; font-size:0.9rem; resize:none; font-family:inherit; outline:none; transition:border-color 0.2s; max-height:120px; overflow-y:auto;"></textarea>
|
|
1868
1878
|
<button id="chat-send" class="btn-send" style="background:linear-gradient(135deg,var(--accent-pink),#a55eea); border:none; border-radius:50%; width:42px; height:42px; color:#fff; font-size:1.1rem; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:transform 0.2s, opacity 0.2s; outline:none;">↑</button>
|
|
1869
1879
|
</div>
|
|
1870
1880
|
</div>
|
|
@@ -1879,18 +1889,18 @@
|
|
|
1879
1889
|
<div id="toast-notification" class="toast-container">Settings saved successfully</div>
|
|
1880
1890
|
|
|
1881
1891
|
<nav class="bottom-nav">
|
|
1882
|
-
<
|
|
1892
|
+
<button type="button" class="nav-item active" data-page="home" aria-selected="true">
|
|
1883
1893
|
<span class="nav-icon">🏠</span>
|
|
1884
1894
|
<span class="nav-label">Home</span>
|
|
1885
|
-
</
|
|
1886
|
-
<
|
|
1895
|
+
</button>
|
|
1896
|
+
<button type="button" class="nav-item" data-page="chat" aria-selected="false">
|
|
1887
1897
|
<span class="nav-icon">💬</span>
|
|
1888
1898
|
<span class="nav-label">Chat</span>
|
|
1889
|
-
</
|
|
1890
|
-
<
|
|
1899
|
+
</button>
|
|
1900
|
+
<button type="button" class="nav-item" data-page="settings" aria-selected="false">
|
|
1891
1901
|
<span class="nav-icon">⚙️</span>
|
|
1892
1902
|
<span class="nav-label">Settings</span>
|
|
1893
|
-
</
|
|
1903
|
+
</button>
|
|
1894
1904
|
</nav>
|
|
1895
1905
|
</div>
|
|
1896
1906
|
|
|
@@ -1901,8 +1911,6 @@
|
|
|
1901
1911
|
'angry': '😤', 'neutral': '😌', 'calm': '😌', 'loving': '💕'
|
|
1902
1912
|
};
|
|
1903
1913
|
|
|
1904
|
-
};
|
|
1905
|
-
|
|
1906
1914
|
const pages = {
|
|
1907
1915
|
home: 'page-home',
|
|
1908
1916
|
chat: 'page-chat',
|
|
@@ -1912,16 +1920,39 @@
|
|
|
1912
1920
|
let conversationCache = [];
|
|
1913
1921
|
let isSettingsLoaded = false;
|
|
1914
1922
|
let isSendingMessage = false;
|
|
1923
|
+
let settingsDirty = false;
|
|
1924
|
+
|
|
1925
|
+
function clampNumber(value, min = 0, max = 1) {
|
|
1926
|
+
const num = Number(value);
|
|
1927
|
+
if (!Number.isFinite(num)) return min;
|
|
1928
|
+
return Math.min(max, Math.max(min, num));
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function toPct(value, min = 0, max = 1) {
|
|
1932
|
+
return Math.round(((clampNumber(value, min, max) - min) / (max - min)) * 100);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function resizeChatInput(inputEl) {
|
|
1936
|
+
if (!inputEl) return;
|
|
1937
|
+
inputEl.style.height = 'auto';
|
|
1938
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
|
1939
|
+
}
|
|
1915
1940
|
|
|
1916
1941
|
// Tab switching
|
|
1917
1942
|
document.addEventListener('DOMContentLoaded', () => {
|
|
1918
1943
|
const navItems = document.querySelectorAll('.nav-item');
|
|
1919
1944
|
const mainEl = document.querySelector('.main');
|
|
1920
1945
|
|
|
1921
|
-
navItems.forEach((el
|
|
1946
|
+
navItems.forEach((el) => {
|
|
1922
1947
|
el.addEventListener('click', () => {
|
|
1923
|
-
|
|
1948
|
+
const requestedPage = el.dataset.page;
|
|
1949
|
+
if (!requestedPage || !pages[requestedPage]) return;
|
|
1950
|
+
navItems.forEach(item => {
|
|
1951
|
+
item.classList.remove('active');
|
|
1952
|
+
item.setAttribute('aria-selected', 'false');
|
|
1953
|
+
});
|
|
1924
1954
|
el.classList.add('active');
|
|
1955
|
+
el.setAttribute('aria-selected', 'true');
|
|
1925
1956
|
|
|
1926
1957
|
// Hide all pages
|
|
1927
1958
|
document.getElementById('page-home').style.display = 'none';
|
|
@@ -1929,8 +1960,7 @@
|
|
|
1929
1960
|
document.getElementById('page-settings').style.display = 'none';
|
|
1930
1961
|
|
|
1931
1962
|
// Show current page
|
|
1932
|
-
|
|
1933
|
-
activePage = pageKeys[index];
|
|
1963
|
+
activePage = requestedPage;
|
|
1934
1964
|
const activePageId = pages[activePage];
|
|
1935
1965
|
const activeEl = document.getElementById(activePageId);
|
|
1936
1966
|
|
|
@@ -1938,12 +1968,15 @@
|
|
|
1938
1968
|
activeEl.style.display = 'flex';
|
|
1939
1969
|
mainEl.classList.add('chat-active');
|
|
1940
1970
|
scrollChatToBottom();
|
|
1971
|
+
} else if (activePage === 'settings') {
|
|
1972
|
+
activeEl.style.display = 'flex';
|
|
1973
|
+
mainEl.classList.remove('chat-active');
|
|
1941
1974
|
} else {
|
|
1942
1975
|
activeEl.style.display = 'block';
|
|
1943
1976
|
mainEl.classList.remove('chat-active');
|
|
1944
1977
|
}
|
|
1945
1978
|
|
|
1946
|
-
if (activePage === 'settings') {
|
|
1979
|
+
if (activePage === 'settings' && (!isSettingsLoaded || !settingsDirty)) {
|
|
1947
1980
|
loadSettings();
|
|
1948
1981
|
}
|
|
1949
1982
|
});
|
|
@@ -1956,6 +1989,7 @@
|
|
|
1956
1989
|
sendBtn.addEventListener('click', sendMessage);
|
|
1957
1990
|
}
|
|
1958
1991
|
if (inputEl) {
|
|
1992
|
+
inputEl.addEventListener('input', () => resizeChatInput(inputEl));
|
|
1959
1993
|
inputEl.addEventListener('keydown', (e) => {
|
|
1960
1994
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1961
1995
|
e.preventDefault();
|
|
@@ -2044,7 +2078,7 @@
|
|
|
2044
2078
|
const resData = await response.json();
|
|
2045
2079
|
if (response.ok && resData.status === 'sent') {
|
|
2046
2080
|
inputEl.value = '';
|
|
2047
|
-
inputEl
|
|
2081
|
+
resizeChatInput(inputEl);
|
|
2048
2082
|
} else {
|
|
2049
2083
|
showToast('Failed to send: ' + (resData.message || 'Unknown error'));
|
|
2050
2084
|
}
|
|
@@ -2063,6 +2097,7 @@
|
|
|
2063
2097
|
async function loadSettings() {
|
|
2064
2098
|
const container = document.getElementById('settings-content');
|
|
2065
2099
|
if (!container) return;
|
|
2100
|
+
if (settingsDirty) return;
|
|
2066
2101
|
|
|
2067
2102
|
try {
|
|
2068
2103
|
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:20px;">Loading settings...</div>';
|
|
@@ -2081,8 +2116,7 @@
|
|
|
2081
2116
|
function renderSettings(settingsData) {
|
|
2082
2117
|
const container = document.getElementById('settings-content');
|
|
2083
2118
|
if (!container) return;
|
|
2084
|
-
|
|
2085
|
-
let html = '';
|
|
2119
|
+
container.replaceChildren();
|
|
2086
2120
|
Object.entries(settingsData).forEach(([filename, fileData]) => {
|
|
2087
2121
|
let contentStr = '';
|
|
2088
2122
|
if (fileData.type === 'json') {
|
|
@@ -2090,25 +2124,50 @@
|
|
|
2090
2124
|
} else {
|
|
2091
2125
|
contentStr = fileData.content;
|
|
2092
2126
|
}
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2127
|
+
|
|
2128
|
+
const card = document.createElement('div');
|
|
2129
|
+
card.className = 'section settings-card';
|
|
2130
|
+
|
|
2131
|
+
const header = document.createElement('div');
|
|
2132
|
+
header.className = 'settings-header-btn-row';
|
|
2133
|
+
|
|
2134
|
+
const title = document.createElement('div');
|
|
2135
|
+
title.className = 'settings-file-title';
|
|
2136
|
+
title.textContent = `⚙️ ${filename}`;
|
|
2137
|
+
|
|
2138
|
+
const saveBtn = document.createElement('button');
|
|
2139
|
+
saveBtn.className = 'btn-save btn-action';
|
|
2140
|
+
saveBtn.type = 'button';
|
|
2141
|
+
saveBtn.dataset.file = filename;
|
|
2142
|
+
saveBtn.textContent = 'Save';
|
|
2143
|
+
|
|
2144
|
+
const grid = document.createElement('div');
|
|
2145
|
+
grid.className = 'settings-grid';
|
|
2146
|
+
|
|
2147
|
+
const textarea = document.createElement('textarea');
|
|
2148
|
+
textarea.className = 'settings-textarea';
|
|
2149
|
+
textarea.dataset.file = filename;
|
|
2150
|
+
textarea.spellcheck = false;
|
|
2151
|
+
textarea.value = contentStr || '';
|
|
2152
|
+
textarea.addEventListener('input', () => {
|
|
2153
|
+
settingsDirty = true;
|
|
2154
|
+
validateJSONInput(textarea, filename);
|
|
2155
|
+
});
|
|
2156
|
+
saveBtn.addEventListener('click', () => saveFileSettings(filename));
|
|
2157
|
+
|
|
2158
|
+
header.append(title, saveBtn);
|
|
2159
|
+
grid.appendChild(textarea);
|
|
2160
|
+
card.append(header, grid);
|
|
2161
|
+
container.appendChild(card);
|
|
2162
|
+
validateJSONInput(textarea, filename);
|
|
2105
2163
|
});
|
|
2106
|
-
|
|
2164
|
+
settingsDirty = false;
|
|
2107
2165
|
}
|
|
2108
2166
|
|
|
2109
2167
|
// Real-time JSON validation
|
|
2110
2168
|
function validateJSONInput(textarea, filename) {
|
|
2111
|
-
const btn = document.
|
|
2169
|
+
const btn = Array.from(document.querySelectorAll('.btn-save'))
|
|
2170
|
+
.find(el => el.dataset.file === filename);
|
|
2112
2171
|
let errorEl = textarea.parentNode.querySelector('.json-error-msg');
|
|
2113
2172
|
|
|
2114
2173
|
if (!errorEl) {
|
|
@@ -2141,7 +2200,8 @@
|
|
|
2141
2200
|
|
|
2142
2201
|
// Settings saving
|
|
2143
2202
|
async function saveFileSettings(filename) {
|
|
2144
|
-
const textarea = document.
|
|
2203
|
+
const textarea = Array.from(document.querySelectorAll('.settings-textarea'))
|
|
2204
|
+
.find(el => el.dataset.file === filename);
|
|
2145
2205
|
if (!textarea) return;
|
|
2146
2206
|
|
|
2147
2207
|
let parsedContent;
|
|
@@ -2156,7 +2216,8 @@
|
|
|
2156
2216
|
parsedContent = textarea.value;
|
|
2157
2217
|
}
|
|
2158
2218
|
|
|
2159
|
-
const btn = document.
|
|
2219
|
+
const btn = Array.from(document.querySelectorAll('.btn-save'))
|
|
2220
|
+
.find(el => el.dataset.file === filename);
|
|
2160
2221
|
if (btn) {
|
|
2161
2222
|
btn.disabled = true;
|
|
2162
2223
|
btn.textContent = 'Saving...';
|
|
@@ -2173,6 +2234,7 @@
|
|
|
2173
2234
|
});
|
|
2174
2235
|
const resData = await response.json();
|
|
2175
2236
|
if (response.ok && resData.status === 'saved') {
|
|
2237
|
+
settingsDirty = false;
|
|
2176
2238
|
showToast(`${filename} saved successfully!`);
|
|
2177
2239
|
} else {
|
|
2178
2240
|
showToast(`Failed to save: ${resData.message || 'Unknown error'}`);
|
|
@@ -2223,7 +2285,7 @@
|
|
|
2223
2285
|
|
|
2224
2286
|
// Emotions - all 17 emotions
|
|
2225
2287
|
['arousal', 'desire', 'love', 'joy', 'sadness', 'trust', 'fear', 'anger', 'boredom', 'guilt', 'pride', 'jealousy', 'embarrassment', 'anticipation', 'hope', 'dread'].forEach(em => {
|
|
2226
|
-
const val =
|
|
2288
|
+
const val = toPct(data[em]);
|
|
2227
2289
|
const valEl = document.getElementById(`val-${em}`);
|
|
2228
2290
|
const barEl = document.getElementById(`bar-${em}`);
|
|
2229
2291
|
if (valEl) valEl.textContent = `${val}%`;
|
|
@@ -2254,22 +2316,26 @@
|
|
|
2254
2316
|
}
|
|
2255
2317
|
|
|
2256
2318
|
// Recent thoughts (subconscious impulses)
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
if (
|
|
2319
|
+
const historyEl = document.getElementById('thought-history');
|
|
2320
|
+
if (historyEl) {
|
|
2321
|
+
if (data.recent_thoughts && data.recent_thoughts.length > 0) {
|
|
2260
2322
|
historyEl.innerHTML = data.recent_thoughts.slice(-5).reverse().map(t =>
|
|
2261
|
-
`<div class="thought-entry"><span class="thought-time">${t.time || ''}</span> <span class="thought-type">${t.type || ''}</span> ${escapeHtml(t.thought || '')}</div>`
|
|
2323
|
+
`<div class="thought-entry"><span class="thought-time">${escapeHtml(t.time || '')}</span> <span class="thought-type">${escapeHtml(t.type || t.thought_type || '')}</span> ${escapeHtml(t.thought || t.content || '')}</div>`
|
|
2262
2324
|
).join('');
|
|
2325
|
+
} else {
|
|
2326
|
+
historyEl.innerHTML = '<div class="thought-entry empty">No recent thoughts yet</div>';
|
|
2263
2327
|
}
|
|
2264
2328
|
}
|
|
2265
2329
|
|
|
2266
2330
|
// Status flags
|
|
2331
|
+
const flagEl = document.getElementById('status-flags');
|
|
2267
2332
|
if (data.is_high_desire || data.is_in_love) {
|
|
2268
2333
|
let flags = [];
|
|
2269
2334
|
if (data.is_in_love) flags.push('In Love');
|
|
2270
2335
|
if (data.is_high_desire) flags.push('High desire');
|
|
2271
|
-
const flagEl = document.getElementById('status-flags');
|
|
2272
2336
|
if (flagEl) flagEl.textContent = flags.join(' + ');
|
|
2337
|
+
} else if (flagEl) {
|
|
2338
|
+
flagEl.textContent = '';
|
|
2273
2339
|
}
|
|
2274
2340
|
}
|
|
2275
2341
|
|
|
@@ -2311,10 +2377,10 @@
|
|
|
2311
2377
|
function updateHormone(name, value) {
|
|
2312
2378
|
const chip = document.getElementById(`hormone-${name}`);
|
|
2313
2379
|
const valEl = document.getElementById(`val-${name}`);
|
|
2314
|
-
const pct =
|
|
2380
|
+
const pct = toPct(value);
|
|
2315
2381
|
if (valEl) valEl.textContent = `${pct}%`;
|
|
2316
2382
|
if (chip) {
|
|
2317
|
-
chip.classList.toggle('elevated', value > 0.6);
|
|
2383
|
+
chip.classList.toggle('elevated', clampNumber(value) > 0.6);
|
|
2318
2384
|
}
|
|
2319
2385
|
}
|
|
2320
2386
|
|
|
@@ -2335,19 +2401,18 @@
|
|
|
2335
2401
|
const state = states[stateName];
|
|
2336
2402
|
if (!state) continue;
|
|
2337
2403
|
|
|
2338
|
-
|
|
2404
|
+
const value = clampNumber(state.current_value, config.min, config.max);
|
|
2339
2405
|
const valEl = document.getElementById(`val-${stateName.replace(/_/g, '-')}`);
|
|
2340
2406
|
const barEl = document.getElementById(`bar-${stateName.replace(/_/g, '-')}`);
|
|
2341
2407
|
|
|
2342
2408
|
if (valEl && barEl) {
|
|
2343
2409
|
if (config.min < 0) {
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
barEl.style.width = `${value * 100}%`;
|
|
2410
|
+
const displayVal = value >= 0 ? `+${Math.round(value * 100)}%` : `${Math.round(value * 100)}%`;
|
|
2411
|
+
valEl.textContent = displayVal;
|
|
2412
|
+
barEl.style.width = `${toPct(value, config.min, config.max)}%`;
|
|
2413
|
+
} else {
|
|
2414
|
+
valEl.textContent = `${toPct(value)}%`;
|
|
2415
|
+
barEl.style.width = `${toPct(value)}%`;
|
|
2351
2416
|
}
|
|
2352
2417
|
}
|
|
2353
2418
|
}
|
|
@@ -2364,13 +2429,13 @@
|
|
|
2364
2429
|
if (!data) return;
|
|
2365
2430
|
|
|
2366
2431
|
const thoughtsEl = document.getElementById('idle-thoughts');
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2432
|
+
if (thoughtsEl && data.recent_thoughts && data.recent_thoughts.length > 0) {
|
|
2433
|
+
thoughtsEl.innerHTML = data.recent_thoughts.slice(0, 5).map(t => `
|
|
2434
|
+
<div class="idle-thought">
|
|
2435
|
+
<div class="thought-type">${escapeHtml(t.thought_type || t.type || 'thought')}</div>
|
|
2436
|
+
${escapeHtml(t.content || t.thought || '')}
|
|
2437
|
+
</div>
|
|
2438
|
+
`).join('');
|
|
2374
2439
|
} else if (thoughtsEl) {
|
|
2375
2440
|
thoughtsEl.innerHTML = '<div class="idle-empty">No recent background thoughts...</div>';
|
|
2376
2441
|
}
|
|
@@ -2387,9 +2452,16 @@
|
|
|
2387
2452
|
const conflicts = data.active_conflicts || data.conflicts || [];
|
|
2388
2453
|
const count = data.count || conflicts.length;
|
|
2389
2454
|
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2455
|
+
if (badgeEl) {
|
|
2456
|
+
badgeEl.textContent = count;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
const tendencyEl = document.getElementById('tendency-badge');
|
|
2460
|
+
if (tendencyEl) {
|
|
2461
|
+
const tendency = String(data.behavioral_tendency || 'neutral').toLowerCase();
|
|
2462
|
+
tendencyEl.textContent = tendency.replace(/_/g, ' ');
|
|
2463
|
+
tendencyEl.className = 'tendency-badge ' + tendency;
|
|
2464
|
+
}
|
|
2393
2465
|
|
|
2394
2466
|
if (conflictsEl && conflicts.length > 0) {
|
|
2395
2467
|
conflictsEl.innerHTML = conflicts.slice(0, 5).map(c => {
|
|
@@ -2399,7 +2471,7 @@
|
|
|
2399
2471
|
const fear = c.fear || c.side_b || '';
|
|
2400
2472
|
// Support both 'tension' (inconsistency API) and 'tension_level' (soul API)
|
|
2401
2473
|
const rawTension = c.tension_level !== undefined ? c.tension_level : (c.tension !== undefined ? c.tension : c.intensity || 0);
|
|
2402
|
-
|
|
2474
|
+
const tension = toPct(rawTension);
|
|
2403
2475
|
|
|
2404
2476
|
return `
|
|
2405
2477
|
<div class="conflict-item">
|
|
@@ -2505,15 +2577,15 @@
|
|
|
2505
2577
|
badge.className = 'circadian-sleep-badge ' + (sleeping ? 'sleeping' : 'awake');
|
|
2506
2578
|
document.getElementById('circadian-sleep-text').textContent = sleeping ? 'Sleeping' : 'Awake';
|
|
2507
2579
|
|
|
2508
|
-
|
|
2580
|
+
const debt = toPct(c.sleep_debt, 0, 2);
|
|
2509
2581
|
document.getElementById('circadian-debt-val').textContent = debt + '%';
|
|
2510
2582
|
document.getElementById('circadian-debt-bar').style.width = debt + '%';
|
|
2511
2583
|
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2584
|
+
const mods = c.modifiers || {};
|
|
2585
|
+
['energy', 'inhibition', 'warmth', 'verbosity'].forEach(m => {
|
|
2586
|
+
const el = document.getElementById('circadian-mod-' + m);
|
|
2587
|
+
if (el) el.textContent = toPct(mods[m]) + '%';
|
|
2588
|
+
});
|
|
2517
2589
|
}
|
|
2518
2590
|
|
|
2519
2591
|
function updateAttachmentUI(a) {
|
|
@@ -2527,7 +2599,7 @@
|
|
|
2527
2599
|
badge.className = 'attachment-style-badge ' + style;
|
|
2528
2600
|
|
|
2529
2601
|
// Fix: API returns 'security', not 'security_score'
|
|
2530
|
-
|
|
2602
|
+
const score = toPct(a.security || a.security_score);
|
|
2531
2603
|
document.getElementById('attachment-security-val').textContent = score + '%';
|
|
2532
2604
|
document.getElementById('attachment-security-bar').style.width = score + '%';
|
|
2533
2605
|
|
|
@@ -2567,7 +2639,7 @@
|
|
|
2567
2639
|
html += '<div class="body-memory-list">';
|
|
2568
2640
|
afterglowList.forEach(a => {
|
|
2569
2641
|
const icon = afterglowIcons[a.type] || '✨';
|
|
2570
|
-
|
|
2642
|
+
const intensity = toPct(a.intensity);
|
|
2571
2643
|
const ago = a.hours_ago ? a.hours_ago.toFixed(1) + 'h ago' : '';
|
|
2572
2644
|
html += `<div class="body-memory-item">
|
|
2573
2645
|
<span class="body-memory-icon">${icon}</span>
|
|
@@ -2584,7 +2656,7 @@
|
|
|
2584
2656
|
if (hasPhantom) {
|
|
2585
2657
|
html += '<div class="body-memory-sub-header">Phantom Sensations</div><div class="body-memory-list">';
|
|
2586
2658
|
phantomList.forEach(p => {
|
|
2587
|
-
|
|
2659
|
+
const intensity = toPct(p.intensity);
|
|
2588
2660
|
html += `<div class="body-memory-item">
|
|
2589
2661
|
<span class="body-memory-icon">👻</span>
|
|
2590
2662
|
<div class="body-memory-content">
|
|
@@ -2643,7 +2715,7 @@
|
|
|
2643
2715
|
topicsArray.sort((a, b) => a.level - b.level);
|
|
2644
2716
|
|
|
2645
2717
|
grid.innerHTML = topicsArray.map(t => {
|
|
2646
|
-
|
|
2718
|
+
const knowledge = toPct(t.level);
|
|
2647
2719
|
const curiosityLevel = 100 - knowledge; // Invert: less knowledge = more curious
|
|
2648
2720
|
const emoji = curiosityLevel > 80 ? '🤔' : curiosityLevel > 50 ? '💭' : '✓';
|
|
2649
2721
|
return `<div class="curiosity-chip">
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Alive-AI WebUI",
|
|
3
|
+
"short_name": "Alive-AI",
|
|
4
|
+
"description": "Local dashboard for Alive-AI runtime state, chat, thoughts, emotions, and settings.",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"scope": "/",
|
|
7
|
+
"display": "standalone",
|
|
8
|
+
"background_color": "#10121f",
|
|
9
|
+
"theme_color": "#00d4ff",
|
|
10
|
+
"icons": [
|
|
11
|
+
{
|
|
12
|
+
"src": "/static/icon.svg",
|
|
13
|
+
"sizes": "any",
|
|
14
|
+
"type": "image/svg+xml",
|
|
15
|
+
"purpose": "any maskable"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|