alive-ai 0.1.13 → 0.1.15

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 CHANGED
@@ -31,7 +31,7 @@ The emotional layer now has real runtime consequences:
31
31
  | Complex emotions | Guilt, pride, jealousy, embarrassment, and anticipation. | They do not just label the dashboard. They push fear, sadness, anger, dominance, trust, arousal, joy, and future-facing behavior in different directions. |
32
32
  | Hormones | Oxytocin, dopamine, serotonin, cortisol, melatonin, plus residual metabolites. | Hormones modulate perception, soul valence/arousal, emotional deltas, somatic body state, interoception, impulse probability, and prompt guidance. Stress makes her more vigilant; bonding increases trust; dopamine increases pursuit; serotonin stabilizes; melatonin slows her down. |
33
33
  | Internal body state | Energy, arousal, certainty, social satiety, cognitive load, connection craving, body sensations, and somatic memories. | The body state is persisted and feeds prompt tone, sleep/rest behavior, and whether she feels steady, overloaded, touchy, open, or withdrawn. |
34
- | Circadian rhythm | Phase, sleep pressure, sleep debt, forced-awake windows, sleep cycle ID, wake time, and sleepiness. | She becomes sleepy, slows down, falls asleep, stops outward proactive behavior while asleep, can be woken by a message, recovers sleep debt, and wakes with lower or higher energy depending on rest. |
34
+ | Circadian rhythm | Phase, sleep pressure, sleep debt in hours, forced-awake windows, sleep cycle ID, wake time, and sleepiness. | She becomes sleepy, slows down, falls asleep, stops outward proactive behavior while asleep, can be woken by a message, recovers sleep debt, and wakes with lower or higher energy depending on rest. After 2am, high sleep pressure shortens user wake-up windows so she can drift back to sleep instead of staying pinned awake. |
35
35
  | Dreams | One normalized dream per sleep cycle, generated from memory fragments and emotion tags. | Dreams are saved, can appear in morning context, and are exposed in the dashboard and static Pages demo. |
36
36
  | Narrative | Relationship phase (first_meeting → bonded) tracked per user. Key moments are detected from message content. | Phase and moment count are injected into the LLM prompt each turn. The dashboard shows the current phase and total key moments. |
37
37
  | Curiosity | Per-user knowledge map across topics detected in messages. Topics range from 0 (unknown) to 1 (well-understood). | When knowledge on a topic is below 0.3 she asks a direct question. At 40% probability otherwise she surfaces curiosity as a prompt hint. Dashboard shows topics sorted by curiosity level. |
@@ -277,7 +277,13 @@ 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 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 so tab navigation, chat, settings, and thought rendering cannot be broken by a syntax error.
280
+ The WebUI hydrates from durable runtime stores instead of only the current browser session. It resolves the active dashboard user from explicit WebUI input, live Telegram activity, configured owner ID, runtime state, and finally the most active user folder on disk. Chat rows are journaled per active user under `data/users/<user>/webui_chat.jsonl` and merged with episodic Telegram conversation history, including legacy flat `data/conversations` history after upgrades. `/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
+ Sleep debt is stored and shown as hours on a 0-8h pressure scale. The UI no longer reports it as a misleading capped percentage, so a persisted `5.6h` debt displays as `5.6h` with the matching pressure bar.
283
+
284
+ 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.
285
+
286
+ 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.
281
287
 
282
288
  GitHub Pages cannot run the Python/FastAPI backend, so the public page includes a static export of the actual WebUI with mocked state:
283
289
 
@@ -251,7 +251,8 @@ class DefaultModeProcessor:
251
251
  if data_path:
252
252
  self.data_path = data_path
253
253
  else:
254
- self.data_path = Path(__file__).parent.parent / "data"
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
 
@@ -26,4 +26,20 @@ if (count === 0) {
26
26
  }
27
27
 
28
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
+ }
29
45
  console.log(`WebUI static check passed (${count} inline script parsed).`);
@@ -264,11 +264,13 @@ 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 INSTANCE-SPECIFIC data path
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
- # Use instance's data path (self.base / "data") for proper isolation
271
- instance_data_path = self.base / "data"
271
+ user_manager = UserManager()
272
+ user_manager.migrate_legacy_data(user_id)
273
+ instance_data_path = user_manager.get_user_paths(user_id)["base"]
272
274
 
273
275
  memory = Memory(
274
276
  nervous=self.nervous,
@@ -462,6 +464,7 @@ async def _process_single_message(self, data: dict):
462
464
  if self._subconscious: self._subconscious.register_interaction()
463
465
  if chat_id: self._default_chat_id = chat_id
464
466
  text = data.get("text", "")
467
+ message_id = data.get("message_id")
465
468
 
466
469
  circadian_interaction = {}
467
470
  if CIRCADIAN_AVAILABLE:
@@ -786,7 +789,7 @@ async def _process_single_message(self, data: dict):
786
789
  # Track if we asked a question (for follow-ups)
787
790
  _follow_up.record_message_sent(response)
788
791
 
789
- await _send_response(self, response, emotion, chat_id, text, user_id)
792
+ await _send_response(self, response, emotion, chat_id, text, user_id, message_id=message_id)
790
793
  if self._subconscious: _feed_learning(self._subconscious, text)
791
794
 
792
795
  # Actually send the media (we already decided what to send)
@@ -1094,7 +1097,7 @@ IMPORTANT: You are sending this media ALONG with your message. Reference it natu
1094
1097
  return fallback_response(emotion, msg)
1095
1098
 
1096
1099
 
1097
- async def _send_response(self, response, emotion, chat_id, text, user_id="default"):
1100
+ async def _send_response(self, response, emotion, chat_id, text, user_id="default", message_id=None):
1098
1101
  mood = emotion.get("mood", "neutral")
1099
1102
 
1100
1103
  # Process any action tags in the response (pass instance config path)
@@ -1123,9 +1126,19 @@ async def _send_response(self, response, emotion, chat_id, text, user_id="defaul
1123
1126
  "chat_id": chat_id,
1124
1127
  "fallback_text": response,
1125
1128
  "mood": mood,
1129
+ "user_id": user_id,
1130
+ "reply_to_message_id": message_id,
1131
+ "source": "runtime",
1126
1132
  })
1127
1133
  return
1128
- await self.nervous.emit("send_text", {"text": response, "mood": mood, "chat_id": chat_id})
1134
+ await self.nervous.emit("send_text", {
1135
+ "text": response,
1136
+ "mood": mood,
1137
+ "chat_id": chat_id,
1138
+ "user_id": user_id,
1139
+ "reply_to_message_id": message_id,
1140
+ "source": "runtime",
1141
+ })
1129
1142
 
1130
1143
 
1131
1144
  def _process_self_authorship_actions(response: str, user_id: str = "default", self_path: Path = None) -> tuple:
@@ -140,9 +140,21 @@ class CircadianEngine:
140
140
  return round(self._clamp(sleepiness), 2)
141
141
 
142
142
  def _should_auto_sleep(self, now: datetime) -> bool:
143
- if self._is_forced_awake(now):
143
+ sleepiness = self.get_sleepiness()
144
+ if not (2 <= now.hour < 6 and sleepiness >= 0.85):
144
145
  return False
145
- return 2 <= now.hour < 6 and self.get_sleepiness() >= 0.85
146
+ if not self._is_forced_awake(now):
147
+ return True
148
+ # After 2am, sleep pressure can override a user wake-up. Messages can
149
+ # briefly rouse her, but they should not pin her awake for another hour.
150
+ if sleepiness >= 0.95:
151
+ return True
152
+ try:
153
+ until = datetime.fromisoformat(self.forced_awake_until) if self.forced_awake_until else now
154
+ forced_started = until - timedelta(minutes=self._forced_awake_duration_minutes(now))
155
+ return (now - forced_started).total_seconds() >= 10 * 60
156
+ except Exception:
157
+ return sleepiness >= 0.9
146
158
 
147
159
  def _should_auto_wake(self, now: datetime) -> bool:
148
160
  slept = self._hours_asleep(now)
@@ -231,9 +243,21 @@ class CircadianEngine:
231
243
  self._save()
232
244
  return True
233
245
 
234
- def stay_up_for_user(self, duration_minutes: int = 45):
246
+ def _forced_awake_duration_minutes(self, now: datetime = None) -> int:
247
+ now = now or self._now()
248
+ sleepiness = self.get_sleepiness()
249
+ if 2 <= now.hour < 6 and sleepiness >= 0.9:
250
+ return 10
251
+ if 0 <= now.hour < 6 and sleepiness >= 0.85:
252
+ return 20
253
+ if now.hour >= 23 and sleepiness >= 0.75:
254
+ return 30
255
+ return 45
256
+
257
+ def stay_up_for_user(self, duration_minutes: int = None):
235
258
  """User is keeping her awake past bedtime."""
236
259
  now = self._now()
260
+ duration_minutes = duration_minutes or self._forced_awake_duration_minutes(now)
237
261
  self.forced_awake = True
238
262
  self.forced_awake_until = (now + timedelta(minutes=duration_minutes)).isoformat()
239
263
  self.last_transition_reason = "staying_up_for_user"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alive-ai",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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/",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alive-ai-runtime"
3
- version = "0.1.13"
3
+ version = "0.1.15"
4
4
  description = "Local-first emotional AI runtime with memory, impulses, and a live dashboard."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
package/webui/app.py CHANGED
@@ -13,6 +13,13 @@ 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
+ count_visible_messages,
19
+ load_chat_messages,
20
+ new_message_id,
21
+ resolve_active_user_id,
22
+ )
16
23
 
17
24
 
18
25
  app = FastAPI(title="Alive-AI Dashboard")
@@ -21,26 +28,45 @@ app = FastAPI(title="Alive-AI Dashboard")
21
28
  _start_time = datetime.now()
22
29
 
23
30
 
24
- def load_persistent_stats() -> dict:
31
+ def load_persistent_stats(active_user: str = None) -> dict:
25
32
  """Load stats from actual data sources on startup"""
26
33
  stats = {"messages": 0, "memories": 0, "evaluations": 0}
27
34
 
28
35
  # Try different base paths
29
36
  base_paths = [data_dir()]
30
37
 
31
- # Count messages from conversation summaries (in users/*/summaries/)
38
+ if active_user:
39
+ try:
40
+ stats["messages"] = count_visible_messages(active_user)
41
+ except Exception:
42
+ pass
43
+
44
+ # Count actual per-user conversation rows and WebUI journal rows.
32
45
  for base_path in base_paths:
33
46
  try:
34
- # Look for summaries in users/*/summaries/
35
47
  users_path = base_path / "users"
36
48
  if users_path.exists():
37
49
  count = 0
38
50
  for user_dir in users_path.iterdir():
39
- summaries_path = user_dir / "summaries"
40
- if summaries_path.exists():
41
- count += len(list(summaries_path.glob("*.json")))
51
+ conv_path = user_dir / "conversations"
52
+ if conv_path.exists():
53
+ for conv_file in conv_path.glob("*.jsonl"):
54
+ with conv_file.open() as fh:
55
+ for line in fh:
56
+ try:
57
+ row = json.loads(line)
58
+ if row.get("user"):
59
+ count += 1
60
+ if row.get("ai"):
61
+ count += 1
62
+ except Exception:
63
+ continue
64
+ journal_path = user_dir / "webui_chat.jsonl"
65
+ if journal_path.exists():
66
+ with journal_path.open() as fh:
67
+ count += sum(1 for _ in fh)
42
68
  if count > 0:
43
- stats["messages"] = count
69
+ stats["messages"] = max(stats["messages"], count)
44
70
  break
45
71
  except Exception:
46
72
  pass
@@ -245,6 +271,79 @@ aliveness_state = {
245
271
  }
246
272
 
247
273
 
274
+ def _active_user_id(explicit=None) -> str:
275
+ return resolve_active_user_id(explicit, self_ref=_self_ref, dashboard_state=alive_ai_state)
276
+
277
+
278
+ def _runtime_state_dict() -> dict:
279
+ runtime_state = getattr(_self_ref, "state", None)
280
+ if runtime_state and hasattr(runtime_state, "to_dict"):
281
+ try:
282
+ return runtime_state.to_dict()
283
+ except Exception:
284
+ return {}
285
+ return {}
286
+
287
+
288
+ def _runtime_chat_ready() -> bool:
289
+ nervous = getattr(_self_ref, "nervous", None)
290
+ listeners = getattr(nervous, "listeners", {}) if nervous else {}
291
+ # Bridge registers one listener; the runtime handler is attached during Self.start().
292
+ return len(listeners.get("message_received", [])) > 1
293
+
294
+
295
+ def _subconscious_thoughts(limit: int = 10) -> list:
296
+ thoughts = []
297
+ sub = getattr(_self_ref, "_subconscious", None)
298
+ wm = getattr(sub, "working_memory", None)
299
+ if wm and hasattr(wm, "get_recent_thoughts"):
300
+ try:
301
+ for thought in wm.get_recent_thoughts(limit):
302
+ thoughts.append({
303
+ "thought": getattr(thought, "content", ""),
304
+ "type": getattr(thought, "type", "reflection"),
305
+ "emotion": getattr(thought, "emotion", {}) or {},
306
+ "time": _format_time(getattr(thought, "created_at", None)),
307
+ })
308
+ except Exception:
309
+ thoughts = []
310
+ if thoughts:
311
+ return thoughts[-limit:]
312
+ return alive_ai_state.get("recent_thoughts", [])[-limit:]
313
+
314
+
315
+ def _format_time(value) -> str:
316
+ if not value:
317
+ return datetime.now().strftime("%H:%M:%S")
318
+ try:
319
+ if isinstance(value, datetime):
320
+ return value.strftime("%H:%M:%S")
321
+ return datetime.fromisoformat(str(value)).strftime("%H:%M:%S")
322
+ except Exception:
323
+ text = str(value)
324
+ return text[11:19] if len(text) >= 19 else text
325
+
326
+
327
+ def build_snapshot(user_id: str = None) -> dict:
328
+ """Compose the dashboard state from live and durable runtime stores."""
329
+ active_user = _active_user_id(user_id)
330
+ snapshot = dict(alive_ai_state)
331
+ snapshot["active_user"] = active_user
332
+ snapshot["runtime"] = _runtime_state_dict()
333
+ snapshot["soul"] = soul_state
334
+ snapshot["aliveness"] = aliveness_state
335
+ snapshot["conversation"] = load_chat_messages(active_user)
336
+ snapshot["stats"] = {
337
+ **snapshot.get("stats", {}),
338
+ **load_persistent_stats(active_user),
339
+ }
340
+ thoughts = _subconscious_thoughts()
341
+ snapshot["recent_thoughts"] = thoughts
342
+ snapshot["current_thought"] = thoughts[-1]["thought"] if thoughts else alive_ai_state.get("current_thought")
343
+ snapshot["updated_at"] = datetime.now().isoformat()
344
+ return snapshot
345
+
346
+
248
347
  def update_state(data: dict):
249
348
  """Called by nervous system to update state"""
250
349
  global alive_ai_state
@@ -255,15 +354,24 @@ def update_state(data: dict):
255
354
  client.set()
256
355
 
257
356
 
258
- def add_conversation(role: str, content: str):
357
+ def add_conversation(role: str, content: str, message_id: str = None,
358
+ status: str = "sent", user_id: str = None,
359
+ source: str = "runtime"):
259
360
  """Add a message to conversation history"""
361
+ if message_id and any(m.get("message_id") == message_id for m in alive_ai_state["conversation"]):
362
+ return
260
363
  alive_ai_state["conversation"].append({
364
+ "message_id": message_id or new_message_id(role),
261
365
  "role": role,
262
366
  "content": content,
263
- "time": datetime.now().strftime("%H:%M:%S")
367
+ "time": datetime.now().strftime("%H:%M:%S"),
368
+ "status": status,
369
+ "source": source,
264
370
  })
265
371
  # Keep last 20 messages
266
372
  alive_ai_state["conversation"] = alive_ai_state["conversation"][-20:]
373
+ if user_id:
374
+ alive_ai_state["active_user"] = user_id
267
375
  if role == "user":
268
376
  alive_ai_state["last_user_message"] = content
269
377
  else:
@@ -316,7 +424,7 @@ async def event_generator(request: Request):
316
424
 
317
425
  try:
318
426
  # Send initial state
319
- yield f"event: state\ndata: {json.dumps(alive_ai_state)}\n\n"
427
+ yield f"event: state\ndata: {json.dumps(build_snapshot())}\n\n"
320
428
 
321
429
  while True:
322
430
  if await request.is_disconnected():
@@ -331,7 +439,7 @@ async def event_generator(request: Request):
331
439
  continue
332
440
 
333
441
  # Send updated state
334
- yield f"event: state\ndata: {json.dumps(alive_ai_state)}\n\n"
442
+ yield f"event: state\ndata: {json.dumps(build_snapshot())}\n\n"
335
443
  except asyncio.CancelledError:
336
444
  pass # Client disconnected normally
337
445
  except Exception as e:
@@ -372,7 +480,7 @@ async def sse_events(request: Request):
372
480
  @app.get("/state")
373
481
  async def get_state():
374
482
  """Get current state (for polling fallback)"""
375
- return alive_ai_state
483
+ return build_snapshot()
376
484
 
377
485
 
378
486
  @app.get("/avatar")
@@ -407,7 +515,7 @@ async def health():
407
515
  @app.get("/api/stats")
408
516
  async def get_persistent_stats():
409
517
  """Get stats refreshed from actual data sources"""
410
- stats = load_persistent_stats()
518
+ stats = load_persistent_stats(_active_user_id())
411
519
 
412
520
  # Update global state with fresh stats
413
521
  alive_ai_state["stats"] = stats
@@ -456,9 +564,10 @@ async def get_memory_status():
456
564
  @app.get("/thoughts")
457
565
  async def get_thoughts():
458
566
  """Get recent thoughts from subconscious"""
567
+ thoughts = _subconscious_thoughts()
459
568
  return {
460
- "current_thought": alive_ai_state.get("current_thought"),
461
- "recent_thoughts": alive_ai_state.get("recent_thoughts", [])
569
+ "current_thought": thoughts[-1]["thought"] if thoughts else alive_ai_state.get("current_thought"),
570
+ "recent_thoughts": thoughts
462
571
  }
463
572
 
464
573
 
@@ -726,7 +835,7 @@ async def get_memory_state():
726
835
  # Try to get fresh data from emotional memory system
727
836
  try:
728
837
  from brain.emotional_memory import get_emotional_memory_system
729
- system = get_emotional_memory_system()
838
+ system = get_emotional_memory_system(_active_user_id())
730
839
  stats = system.get_stats()
731
840
  recent_high = system.get_recent_high_emotion(hours=24, limit=1)
732
841
 
@@ -851,8 +960,7 @@ async def get_new_aliveness():
851
960
  try:
852
961
  from brain.narrative import get_narrative_engine
853
962
  ne = get_narrative_engine()
854
- from core.settings import get as settings_get
855
- owner_id = str(settings_get("TELEGRAM_OWNER_ID", ""))
963
+ owner_id = _active_user_id()
856
964
 
857
965
  # Fallback: when owner_id is empty (terminal mode), find the most active user
858
966
  if not owner_id:
@@ -887,7 +995,7 @@ async def get_new_aliveness():
887
995
 
888
996
  result["narrative"] = {
889
997
  "phase": data.get("phase", "first_meeting"),
890
- "message_count": msg_count,
998
+ "message_count": max(msg_count, count_visible_messages(owner_id)),
891
999
  "moments": len(data.get("key_moments", []))
892
1000
  }
893
1001
  else:
@@ -906,8 +1014,7 @@ async def get_new_aliveness():
906
1014
  # Linguistic
907
1015
  try:
908
1016
  from brain.linguistic import get_linguistic_profile
909
- from core.settings import get as settings_get
910
- owner_id = str(settings_get("TELEGRAM_OWNER_ID", ""))
1017
+ owner_id = _active_user_id()
911
1018
  if owner_id:
912
1019
  lp = get_linguistic_profile(owner_id)
913
1020
  patterns = lp.get_absorbed_patterns() if hasattr(lp, 'get_absorbed_patterns') else {}
@@ -925,8 +1032,7 @@ async def get_new_aliveness():
925
1032
  # Curiosity
926
1033
  try:
927
1034
  from brain.curiosity import get_curiosity_drive
928
- from core.settings import get as settings_get
929
- owner_id = str(settings_get("TELEGRAM_OWNER_ID", ""))
1035
+ owner_id = _active_user_id()
930
1036
 
931
1037
  # Fallback: when owner_id is empty (terminal mode), find the most active user
932
1038
  if not owner_id:
@@ -966,22 +1072,35 @@ async def get_new_aliveness():
966
1072
  async def chat_endpoint(request: Request, background_tasks: BackgroundTasks):
967
1073
  data = await request.json()
968
1074
  text = data.get("text", "").strip()
969
- if not text or not _self_ref:
1075
+ if not text or not _self_ref or not _runtime_chat_ready():
970
1076
  return JSONResponse({"status": "error", "message": "No text or AI not ready"}, 400)
971
- # Add user message immediately to conversation
972
- add_conversation("user", text)
1077
+ user_id = _active_user_id(data.get("user_id"))
1078
+ message_id = data.get("message_id") or new_message_id("webui_user")
1079
+ append_chat_message(user_id, "user", text, message_id=message_id, status="pending", source="webui")
1080
+ add_conversation("user", text, message_id=message_id, status="pending", user_id=user_id, source="webui")
973
1081
  update_state({})
974
- # Fire message handler in background
1082
+
975
1083
  async def _send():
976
- from core.message_handler import handle_message
977
- await handle_message(_self_ref, {
978
- "user_id": "webui",
979
- "text": text,
980
- "chat_id": "webui",
981
- "source": "webui"
982
- })
1084
+ try:
1085
+ await _self_ref.nervous.emit("message_received", {
1086
+ "message_id": message_id,
1087
+ "user_id": user_id,
1088
+ "webui_user_id": user_id,
1089
+ "text": text,
1090
+ "chat_id": "webui",
1091
+ "source": "webui"
1092
+ })
1093
+ except Exception as e:
1094
+ append_chat_message(
1095
+ user_id,
1096
+ "alive_ai",
1097
+ f"Something went wrong while processing that message: {e}",
1098
+ status="error",
1099
+ source="webui",
1100
+ )
1101
+ update_state({"thinking": False})
983
1102
  background_tasks.add_task(_send)
984
- return JSONResponse({"status": "sent"})
1103
+ return JSONResponse({"status": "sent", "message_id": message_id, "user_id": user_id})
985
1104
 
986
1105
 
987
1106
  @app.get("/api/settings")
@@ -1013,13 +1132,18 @@ async def save_settings(request: Request):
1013
1132
  if fname not in allowed:
1014
1133
  return JSONResponse({"status": "error", "message": "Invalid file"}, 400)
1015
1134
  config_dir = Path(os.environ.get("ALIVE_AI_ROOT", ".")) / "config"
1135
+ config_dir.mkdir(parents=True, exist_ok=True)
1016
1136
  p = config_dir / fname
1017
1137
  content = data.get("content")
1018
1138
  try:
1019
1139
  if fname.endswith(".json"):
1020
- p.write_text(json.dumps(content, indent=2, ensure_ascii=False))
1140
+ text = json.dumps(content, indent=2, ensure_ascii=False) + "\n"
1141
+ json.loads(text)
1021
1142
  else:
1022
- p.write_text(content)
1143
+ text = str(content or "")
1144
+ tmp = p.with_suffix(p.suffix + ".tmp")
1145
+ tmp.write_text(text)
1146
+ tmp.replace(p)
1023
1147
  return {"status": "saved"}
1024
1148
  except Exception as e:
1025
1149
  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
- add_conversation("alive_ai", text)
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("user_id", "unknown")
58
- add_conversation("user", text)
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