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 +8 -2
- package/brain/default_mode.py +2 -1
- package/cli/check_webui_static.js +16 -0
- package/core/message_handler.py +19 -6
- package/heart/circadian.py +27 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/webui/app.py +161 -37
- package/webui/bridge.py +14 -4
- package/webui/persistence.py +268 -0
- package/webui/static/index.html +164 -82
|
@@ -0,0 +1,268 @@
|
|
|
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, Tuple
|
|
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 _configured_owner_id() -> str:
|
|
30
|
+
owner = os.environ.get("TELEGRAM_OWNER_ID", "")
|
|
31
|
+
if owner:
|
|
32
|
+
return owner
|
|
33
|
+
try:
|
|
34
|
+
from core.settings import get as settings_get
|
|
35
|
+
return str(settings_get("TELEGRAM_OWNER_ID", "") or "")
|
|
36
|
+
except Exception:
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _tracked_active_user_id() -> str:
|
|
41
|
+
try:
|
|
42
|
+
from core.user_tracker import get_user_tracker
|
|
43
|
+
active = get_user_tracker().get_active_users(within_minutes=24 * 60)
|
|
44
|
+
if active:
|
|
45
|
+
active = sorted(active, key=lambda u: u.last_interaction, reverse=True)
|
|
46
|
+
return active[0].user_id
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _path_activity_score(path: Path) -> Tuple[float, int]:
|
|
53
|
+
latest = path.stat().st_mtime if path.exists() else 0.0
|
|
54
|
+
count = 0
|
|
55
|
+
for pattern in ("conversations/*.jsonl", "webui_chat.jsonl", "narrative.json",
|
|
56
|
+
"facts.json", "emotional_memories.json"):
|
|
57
|
+
for item in path.glob(pattern):
|
|
58
|
+
try:
|
|
59
|
+
latest = max(latest, item.stat().st_mtime)
|
|
60
|
+
if item.is_file():
|
|
61
|
+
count += 1
|
|
62
|
+
except Exception:
|
|
63
|
+
continue
|
|
64
|
+
return latest, count
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _most_active_disk_user_id() -> str:
|
|
68
|
+
users = data_dir() / "users"
|
|
69
|
+
if not users.exists():
|
|
70
|
+
return ""
|
|
71
|
+
candidates = []
|
|
72
|
+
for child in users.iterdir():
|
|
73
|
+
if not child.is_dir() or child.name in {"default", "webui"}:
|
|
74
|
+
continue
|
|
75
|
+
latest, count = _path_activity_score(child)
|
|
76
|
+
if count:
|
|
77
|
+
candidates.append((latest, child.name))
|
|
78
|
+
if not candidates:
|
|
79
|
+
return ""
|
|
80
|
+
return max(candidates)[1]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_active_user_id(explicit: Any = None, self_ref: Any = None,
|
|
84
|
+
dashboard_state: Optional[Dict[str, Any]] = None) -> str:
|
|
85
|
+
if explicit:
|
|
86
|
+
return normalize_user_id(explicit)
|
|
87
|
+
|
|
88
|
+
dashboard_state = dashboard_state or {}
|
|
89
|
+
active = dashboard_state.get("active_user")
|
|
90
|
+
if active and normalize_user_id(active) not in {"default", "webui"}:
|
|
91
|
+
return normalize_user_id(dashboard_state["active_user"])
|
|
92
|
+
|
|
93
|
+
tracked = _tracked_active_user_id()
|
|
94
|
+
if tracked:
|
|
95
|
+
return normalize_user_id(tracked)
|
|
96
|
+
|
|
97
|
+
owner = _configured_owner_id()
|
|
98
|
+
if owner:
|
|
99
|
+
return normalize_user_id(owner)
|
|
100
|
+
|
|
101
|
+
runtime_state = getattr(self_ref, "state", None)
|
|
102
|
+
runtime_user = getattr(runtime_state, "user_id", None) if runtime_state else None
|
|
103
|
+
if runtime_user and normalize_user_id(runtime_user) not in {"default", "webui"}:
|
|
104
|
+
return normalize_user_id(runtime_state.user_id)
|
|
105
|
+
|
|
106
|
+
disk_user = _most_active_disk_user_id()
|
|
107
|
+
if disk_user:
|
|
108
|
+
return normalize_user_id(disk_user)
|
|
109
|
+
|
|
110
|
+
if active:
|
|
111
|
+
return normalize_user_id(active)
|
|
112
|
+
|
|
113
|
+
if runtime_user:
|
|
114
|
+
return normalize_user_id(runtime_user)
|
|
115
|
+
|
|
116
|
+
return "webui"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def user_base(user_id: str) -> Path:
|
|
120
|
+
path = data_dir() / "users" / normalize_user_id(user_id)
|
|
121
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
return path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def chat_journal_path(user_id: str) -> Path:
|
|
126
|
+
return user_base(user_id) / "webui_chat.jsonl"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def new_message_id(prefix: str = "msg") -> str:
|
|
130
|
+
return f"{prefix}_{uuid.uuid4().hex[:16]}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_jsonl(path: Path) -> List[Dict[str, Any]]:
|
|
134
|
+
rows: List[Dict[str, Any]] = []
|
|
135
|
+
if not path.exists():
|
|
136
|
+
return rows
|
|
137
|
+
with path.open() as fh:
|
|
138
|
+
for line in fh:
|
|
139
|
+
try:
|
|
140
|
+
row = json.loads(line)
|
|
141
|
+
if isinstance(row, dict):
|
|
142
|
+
rows.append(row)
|
|
143
|
+
except Exception:
|
|
144
|
+
continue
|
|
145
|
+
return rows
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def append_chat_message(user_id: str, role: str, content: str,
|
|
149
|
+
message_id: Optional[str] = None,
|
|
150
|
+
status: str = "sent", source: str = "runtime",
|
|
151
|
+
metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
152
|
+
message_id = message_id or new_message_id(role)
|
|
153
|
+
entry = {
|
|
154
|
+
"message_id": message_id,
|
|
155
|
+
"role": role,
|
|
156
|
+
"content": str(content or ""),
|
|
157
|
+
"status": status,
|
|
158
|
+
"source": source,
|
|
159
|
+
"timestamp": datetime.now().isoformat(),
|
|
160
|
+
}
|
|
161
|
+
if metadata:
|
|
162
|
+
entry["metadata"] = metadata
|
|
163
|
+
|
|
164
|
+
path = chat_journal_path(user_id)
|
|
165
|
+
existing = _read_jsonl(path)
|
|
166
|
+
for idx, row in enumerate(existing):
|
|
167
|
+
if row.get("message_id") == message_id:
|
|
168
|
+
merged = {**row, **{k: v for k, v in entry.items() if v not in (None, "")}}
|
|
169
|
+
existing[idx] = merged
|
|
170
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
171
|
+
with tmp.open("w") as fh:
|
|
172
|
+
for item in existing:
|
|
173
|
+
fh.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
174
|
+
tmp.replace(path)
|
|
175
|
+
return entry
|
|
176
|
+
with path.open("a") as fh:
|
|
177
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
178
|
+
return entry
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _format_entry(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
182
|
+
timestamp = row.get("timestamp") or row.get("created_at") or ""
|
|
183
|
+
time_label = ""
|
|
184
|
+
if timestamp:
|
|
185
|
+
try:
|
|
186
|
+
time_label = datetime.fromisoformat(timestamp).strftime("%H:%M:%S")
|
|
187
|
+
except Exception:
|
|
188
|
+
time_label = str(timestamp)[11:19] if len(str(timestamp)) >= 19 else ""
|
|
189
|
+
return {
|
|
190
|
+
"message_id": row.get("message_id") or new_message_id("legacy"),
|
|
191
|
+
"role": row.get("role", "assistant"),
|
|
192
|
+
"content": row.get("content", ""),
|
|
193
|
+
"time": time_label,
|
|
194
|
+
"timestamp": timestamp,
|
|
195
|
+
"status": row.get("status", "sent"),
|
|
196
|
+
"source": row.get("source", "runtime"),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _load_journal(user_id: str) -> List[Dict[str, Any]]:
|
|
201
|
+
return [_format_entry(row) for row in _read_jsonl(chat_journal_path(user_id))]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _load_episodic_fallback(user_id: str, limit_turns: int) -> List[Dict[str, Any]]:
|
|
205
|
+
base = user_base(user_id) / "conversations"
|
|
206
|
+
legacy = data_dir() / "conversations"
|
|
207
|
+
conv_dirs = [base]
|
|
208
|
+
if legacy != base:
|
|
209
|
+
conv_dirs.append(legacy)
|
|
210
|
+
bot_prefixed = [p for p in (data_dir() / "users").glob(f"*_{normalize_user_id(user_id)}")
|
|
211
|
+
if (p / "conversations").exists()]
|
|
212
|
+
conv_dirs.extend(p / "conversations" for p in bot_prefixed)
|
|
213
|
+
|
|
214
|
+
existing_dirs = [p for p in conv_dirs if p.exists() and list(p.glob("*.jsonl"))]
|
|
215
|
+
if not existing_dirs:
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
turns: List[Dict[str, Any]] = []
|
|
219
|
+
for conv_dir in existing_dirs:
|
|
220
|
+
for file in sorted(conv_dir.glob("*.jsonl"), reverse=True):
|
|
221
|
+
file_rows = _read_jsonl(file)
|
|
222
|
+
turns.extend(reversed(file_rows))
|
|
223
|
+
turns = sorted(turns, key=lambda row: row.get("timestamp", ""), reverse=True)[:limit_turns]
|
|
224
|
+
|
|
225
|
+
messages: List[Dict[str, Any]] = []
|
|
226
|
+
for row in reversed(turns):
|
|
227
|
+
ts = row.get("timestamp", "")
|
|
228
|
+
if row.get("user"):
|
|
229
|
+
messages.append(_format_entry({
|
|
230
|
+
"message_id": f"legacy_user_{len(messages)}_{ts}",
|
|
231
|
+
"role": "user",
|
|
232
|
+
"content": row.get("user", ""),
|
|
233
|
+
"timestamp": ts,
|
|
234
|
+
"source": "episodic",
|
|
235
|
+
}))
|
|
236
|
+
if row.get("ai"):
|
|
237
|
+
messages.append(_format_entry({
|
|
238
|
+
"message_id": f"legacy_ai_{len(messages)}_{ts}",
|
|
239
|
+
"role": "alive_ai",
|
|
240
|
+
"content": row.get("ai", ""),
|
|
241
|
+
"timestamp": ts,
|
|
242
|
+
"source": "episodic",
|
|
243
|
+
}))
|
|
244
|
+
return messages
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_chat_messages(user_id: str, limit: int = 60) -> List[Dict[str, Any]]:
|
|
248
|
+
if limit and limit > 0:
|
|
249
|
+
episodic_limit = max(1, limit // 2)
|
|
250
|
+
else:
|
|
251
|
+
episodic_limit = 1_000_000
|
|
252
|
+
messages = _load_episodic_fallback(user_id, episodic_limit)
|
|
253
|
+
messages.extend(_load_journal(user_id))
|
|
254
|
+
|
|
255
|
+
deduped: Dict[str, Dict[str, Any]] = {}
|
|
256
|
+
for msg in messages:
|
|
257
|
+
key = msg.get("message_id") or f"{msg.get('role')}:{msg.get('timestamp')}:{msg.get('content')}"
|
|
258
|
+
deduped[key] = msg
|
|
259
|
+
|
|
260
|
+
ordered = sorted(
|
|
261
|
+
deduped.values(),
|
|
262
|
+
key=lambda m: m.get("timestamp") or ""
|
|
263
|
+
)
|
|
264
|
+
return ordered[-limit:] if limit and limit > 0 else ordered
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def count_visible_messages(user_id: str) -> int:
|
|
268
|
+
return len(load_chat_messages(user_id, limit=0))
|