alive-ai 0.1.11 → 0.1.13

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
@@ -33,10 +33,14 @@ The emotional layer now has real runtime consequences:
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
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. |
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
+ | 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
+ | 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. |
38
+ | Internal conflicts | Five persistent desire-vs-fear tensions (closeness/independence, passion/comfort, stability/growth, etc.) with a swinging balance that is saved between sessions. | Every message triggers conflicts whose keywords match the topic. Balance swings over time. Conflicts with tension > 0 surface in the prompt and are visible on the dashboard with a tension bar. |
36
39
  | Persistence | Emotion, attachment, soul, somatic, unconscious, conflict, subconscious, circadian, and dream state under `data/`. | Restarting the runtime preserves the inner state instead of visually resetting it. |
37
40
 
38
41
  The public Pages site is a static explanation and dashboard export. The local WebUI is the live version: it streams the actual state from the running Python backend.
39
42
 
43
+
40
44
  ## Quick Start
41
45
 
42
46
  ```bash
@@ -273,6 +277,8 @@ The real WebUI streams local runtime state over Server-Sent Events and shows:
273
277
  - attachment, circadian rhythm, sleepiness, body memory, dreams, curiosity, and conflicts,
274
278
  - runtime health through local endpoints.
275
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.
281
+
276
282
  GitHub Pages cannot run the Python/FastAPI backend, so the public page includes a static export of the actual WebUI with mocked state:
277
283
 
278
284
  ```text
@@ -5,6 +5,7 @@ enabling natural references to shared history and phase awareness.
5
5
  """
6
6
 
7
7
  from datetime import datetime
8
+ from pathlib import Path
8
9
  from typing import Dict, List, Optional
9
10
  import json
10
11
  import random
@@ -0,0 +1,29 @@
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
+ console.log(`WebUI static check passed (${count} inline script parsed).`);
package/main.py CHANGED
@@ -122,7 +122,7 @@ async def main() -> None:
122
122
  try:
123
123
  from webui.bridge import init_bridge, init_soul_bridge, start_webui
124
124
 
125
- init_bridge(ai.nervous)
125
+ init_bridge(ai.nervous, ai=ai)
126
126
  if hasattr(ai, "_heart") and ai._heart and hasattr(ai._heart, "soul"):
127
127
  init_soul_bridge(ai._heart.soul)
128
128
  webui_task = asyncio.create_task(start_webui(host="127.0.0.1", port=webui_port))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alive-ai",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alive-ai-runtime"
3
- version = "0.1.11"
3
+ version = "0.1.13"
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
@@ -9,8 +9,8 @@ from datetime import datetime
9
9
  from pathlib import Path
10
10
  from typing import Optional, Dict, List, Any
11
11
  from collections import deque
12
- from fastapi import FastAPI, Request
13
- from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
12
+ from fastapi import FastAPI, Request, BackgroundTasks
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
16
 
@@ -128,6 +128,7 @@ alive_ai_state = {
128
128
  "last_user_message": None,
129
129
  "stats": _persistent_stats,
130
130
  "conversation": [],
131
+ "thinking": False,
131
132
  "recent_thoughts": [],
132
133
  "updated_at": datetime.now().isoformat(),
133
134
  "start_time": _start_time.isoformat(),
@@ -194,6 +195,15 @@ soul_history: deque = deque(maxlen=100)
194
195
  # Reference to Soul Orchestrator (set by bridge)
195
196
  _soul_orchestrator = None
196
197
 
198
+ # Reference to AI Instance (set by bridge)
199
+ _self_ref = None
200
+
201
+
202
+ def set_self_ref(ai):
203
+ global _self_ref
204
+ _self_ref = ai
205
+
206
+
197
207
  # Connected clients for SSE
198
208
  clients = []
199
209
 
@@ -338,6 +348,13 @@ async def dashboard():
338
348
  return HTMLResponse(content=html_path.read_text())
339
349
 
340
350
 
351
+ @app.get("/favicon.ico")
352
+ async def favicon():
353
+ """Serve the dashboard icon for browsers that still request /favicon.ico."""
354
+ icon_path = Path(__file__).parent / "static" / "icon.svg"
355
+ return FileResponse(icon_path, media_type="image/svg+xml")
356
+
357
+
341
358
  @app.get("/events")
342
359
  async def sse_events(request: Request):
343
360
  """SSE endpoint for real-time updates"""
@@ -834,9 +851,24 @@ async def get_new_aliveness():
834
851
  try:
835
852
  from brain.narrative import get_narrative_engine
836
853
  ne = get_narrative_engine()
837
- # Get owner's narrative
838
854
  from core.settings import get as settings_get
839
855
  owner_id = str(settings_get("TELEGRAM_OWNER_ID", ""))
856
+
857
+ # Fallback: when owner_id is empty (terminal mode), find the most active user
858
+ if not owner_id:
859
+ try:
860
+ users_path = data_dir() / "users"
861
+ if users_path.exists():
862
+ candidates = [d.name for d in users_path.iterdir() if d.is_dir()]
863
+ if candidates:
864
+ # Pick the user with the most recent narrative file
865
+ def _narr_mtime(uid):
866
+ p = data_dir() / "users" / uid / "narrative.json"
867
+ return p.stat().st_mtime if p.exists() else 0
868
+ owner_id = max(candidates, key=_narr_mtime)
869
+ except Exception:
870
+ pass
871
+
840
872
  if owner_id:
841
873
  data = ne._get_data(owner_id)
842
874
  msg_count = data.get("message_count", 0)
@@ -895,6 +927,21 @@ async def get_new_aliveness():
895
927
  from brain.curiosity import get_curiosity_drive
896
928
  from core.settings import get as settings_get
897
929
  owner_id = str(settings_get("TELEGRAM_OWNER_ID", ""))
930
+
931
+ # Fallback: when owner_id is empty (terminal mode), find the most active user
932
+ if not owner_id:
933
+ try:
934
+ users_path = data_dir() / "users"
935
+ if users_path.exists():
936
+ candidates = [d.name for d in users_path.iterdir() if d.is_dir()]
937
+ if candidates:
938
+ def _cur_mtime(uid):
939
+ p = data_dir() / "users" / uid / "curiosity.json"
940
+ return p.stat().st_mtime if p.exists() else 0
941
+ owner_id = max(candidates, key=_cur_mtime)
942
+ except Exception:
943
+ pass
944
+
898
945
  if owner_id:
899
946
  cd = get_curiosity_drive(owner_id)
900
947
  topics = {t: round(v, 2) for t, v in cd.knowledge.items()} if hasattr(cd, 'knowledge') else {}
@@ -915,6 +962,69 @@ async def get_new_aliveness():
915
962
  return result
916
963
 
917
964
 
965
+ @app.post("/api/chat")
966
+ async def chat_endpoint(request: Request, background_tasks: BackgroundTasks):
967
+ data = await request.json()
968
+ text = data.get("text", "").strip()
969
+ if not text or not _self_ref:
970
+ return JSONResponse({"status": "error", "message": "No text or AI not ready"}, 400)
971
+ # Add user message immediately to conversation
972
+ add_conversation("user", text)
973
+ update_state({})
974
+ # Fire message handler in background
975
+ 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
+ })
983
+ background_tasks.add_task(_send)
984
+ return JSONResponse({"status": "sent"})
985
+
986
+
987
+ @app.get("/api/settings")
988
+ async def get_settings():
989
+ import json
990
+ from pathlib import Path
991
+ config_dir = Path(os.environ.get("ALIVE_AI_ROOT", ".")) / "config"
992
+ result = {}
993
+ for fname in ["settings.json", "self.json", "directives.json"]:
994
+ p = config_dir / fname
995
+ if p.exists():
996
+ try:
997
+ result[fname] = {"type": "json", "content": json.loads(p.read_text())}
998
+ except Exception:
999
+ result[fname] = {"type": "json", "content": {}}
1000
+ p = config_dir / "instructions.md"
1001
+ if p.exists():
1002
+ result["instructions.md"] = {"type": "markdown", "content": p.read_text()}
1003
+ return result
1004
+
1005
+
1006
+ @app.post("/api/settings")
1007
+ async def save_settings(request: Request):
1008
+ import json
1009
+ from pathlib import Path
1010
+ data = await request.json()
1011
+ fname = data.get("file", "")
1012
+ allowed = {"settings.json", "self.json", "directives.json", "instructions.md"}
1013
+ if fname not in allowed:
1014
+ return JSONResponse({"status": "error", "message": "Invalid file"}, 400)
1015
+ config_dir = Path(os.environ.get("ALIVE_AI_ROOT", ".")) / "config"
1016
+ p = config_dir / fname
1017
+ content = data.get("content")
1018
+ try:
1019
+ if fname.endswith(".json"):
1020
+ p.write_text(json.dumps(content, indent=2, ensure_ascii=False))
1021
+ else:
1022
+ p.write_text(content)
1023
+ return {"status": "saved"}
1024
+ except Exception as e:
1025
+ return JSONResponse({"status": "error", "message": str(e)}, 500)
1026
+
1027
+
918
1028
  # Mount static files
919
1029
  static_path = Path(__file__).parent / "static"
920
1030
  if static_path.exists():
package/webui/bridge.py CHANGED
@@ -14,8 +14,11 @@ from .app import (
14
14
  _webui_server = None
15
15
 
16
16
 
17
- def init_bridge(nervous):
17
+ def init_bridge(nervous, ai=None):
18
18
  """Connect nervous system events to webui updates"""
19
+ if ai is not None:
20
+ from .app import set_self_ref
21
+ set_self_ref(ai)
19
22
 
20
23
  async def on_emotion(data):
21
24
  """Update emotion state in dashboard"""
@@ -178,6 +181,16 @@ def init_bridge(nervous):
178
181
  except Exception as e:
179
182
  print(f"[WebUI] Error updating inconsistency state: {e}")
180
183
 
184
+ async def on_thinking_start(data):
185
+ """Set thinking state to True"""
186
+ alive_ai_state["thinking"] = True
187
+ update_state({})
188
+
189
+ async def on_thinking_done(data):
190
+ """Set thinking state to False"""
191
+ alive_ai_state["thinking"] = False
192
+ update_state({})
193
+
181
194
  # Register event listeners
182
195
  nervous.on("emotion_update", on_emotion)
183
196
  nervous.on("send_text", on_message_sent)
@@ -187,6 +200,8 @@ def init_bridge(nervous):
187
200
  nervous.on("subconscious_impulse", on_subconscious)
188
201
  nervous.on("subconscious_thought", on_subconscious_thought)
189
202
  nervous.on("soul_tick", on_soul_tick_event)
203
+ nervous.on("thinking_start", on_thinking_start)
204
+ nervous.on("thinking_done", on_thinking_done)
190
205
 
191
206
  # Register aliveness event listeners
192
207
  nervous.on("interoceptive_update", on_interoceptive_update)
@@ -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>
@@ -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>
@@ -1064,6 +1066,277 @@
1064
1066
  0% { background-position: 200% 0; }
1065
1067
  100% { background-position: -200% 0; }
1066
1068
  }
1069
+
1070
+ /* Multi-Page Dashboard & Tab Switching */
1071
+ .main.chat-active {
1072
+ overflow-y: hidden;
1073
+ padding-bottom: 0;
1074
+ display: flex;
1075
+ flex-direction: column;
1076
+ height: calc(100vh - 75px - var(--safe-top) - var(--safe-bottom) - 75px);
1077
+ }
1078
+
1079
+ .page-section {
1080
+ width: 100%;
1081
+ }
1082
+
1083
+ /* Chat Page Styles */
1084
+ #page-chat {
1085
+ flex: 1;
1086
+ display: flex;
1087
+ flex-direction: column;
1088
+ height: 100%;
1089
+ position: relative;
1090
+ }
1091
+
1092
+ .chat-messages {
1093
+ flex: 1;
1094
+ overflow-y: auto;
1095
+ padding: 10px 0;
1096
+ display: flex;
1097
+ flex-direction: column;
1098
+ gap: 12px;
1099
+ scroll-behavior: smooth;
1100
+ }
1101
+
1102
+ /* Scrollbar styling for chat */
1103
+ .chat-messages::-webkit-scrollbar {
1104
+ width: 6px;
1105
+ }
1106
+ .chat-messages::-webkit-scrollbar-track {
1107
+ background: transparent;
1108
+ }
1109
+ .chat-messages::-webkit-scrollbar-thumb {
1110
+ background: rgba(255, 255, 255, 0.1);
1111
+ border-radius: 3px;
1112
+ }
1113
+
1114
+ .chat-msg-row {
1115
+ display: flex;
1116
+ width: 100%;
1117
+ margin-bottom: 4px;
1118
+ }
1119
+ .chat-msg-row.user {
1120
+ justify-content: flex-end;
1121
+ }
1122
+ .chat-msg-row.alive-ai {
1123
+ justify-content: flex-start;
1124
+ }
1125
+
1126
+ .chat-bubble {
1127
+ max-width: 80%;
1128
+ padding: 12px 16px;
1129
+ font-size: 0.95rem;
1130
+ line-height: 1.45;
1131
+ position: relative;
1132
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
1133
+ transition: all 0.2s ease;
1134
+ }
1135
+
1136
+ .chat-msg-row.user .chat-bubble {
1137
+ background: linear-gradient(135deg, var(--accent-pink) 0%, #a55eea 100%);
1138
+ color: #fff;
1139
+ border-radius: 20px 20px 4px 20px;
1140
+ border: 1px solid rgba(255, 255, 255, 0.1);
1141
+ }
1142
+
1143
+ .chat-msg-row.alive-ai .chat-bubble {
1144
+ background: rgba(255, 255, 255, 0.04);
1145
+ backdrop-filter: blur(10px);
1146
+ -webkit-backdrop-filter: blur(10px);
1147
+ border: 1px solid rgba(255, 255, 255, 0.08);
1148
+ color: var(--text-primary);
1149
+ border-radius: 20px 20px 20px 4px;
1150
+ }
1151
+
1152
+ .chat-bubble-time {
1153
+ font-size: 0.7rem;
1154
+ color: rgba(255, 255, 255, 0.5);
1155
+ margin-top: 4px;
1156
+ text-align: right;
1157
+ display: block;
1158
+ }
1159
+ .chat-msg-row.alive-ai .chat-bubble-time {
1160
+ color: var(--text-muted);
1161
+ }
1162
+
1163
+ /* Typing Dots Animation */
1164
+ .typing-indicator {
1165
+ display: inline-flex;
1166
+ align-items: center;
1167
+ gap: 4px;
1168
+ padding: 6px 12px;
1169
+ background: rgba(255, 255, 255, 0.03);
1170
+ border: 1px solid rgba(255, 255, 255, 0.05);
1171
+ border-radius: 12px;
1172
+ margin-top: 4px;
1173
+ color: var(--accent-pink);
1174
+ font-size: 0.85rem;
1175
+ }
1176
+
1177
+ .typing-dots {
1178
+ display: flex;
1179
+ align-items: center;
1180
+ gap: 4px;
1181
+ }
1182
+
1183
+ .typing-dots span {
1184
+ width: 6px;
1185
+ height: 6px;
1186
+ background-color: var(--accent-pink);
1187
+ border-radius: 50%;
1188
+ display: inline-block;
1189
+ animation: bounce 1.4s infinite ease-in-out both;
1190
+ }
1191
+
1192
+ .typing-dots span:nth-child(1) { animation-delay: -0.32s; }
1193
+ .typing-dots span:nth-child(2) { animation-delay: -0.16s; }
1194
+
1195
+ @keyframes bounce {
1196
+ 0%, 80%, 100% { transform: scale(0); }
1197
+ 40% { transform: scale(1.0); }
1198
+ }
1199
+
1200
+ /* Settings CSS */
1201
+ .settings-card {
1202
+ margin-bottom: 20px;
1203
+ }
1204
+ .settings-header-btn-row {
1205
+ display: flex;
1206
+ justify-content: space-between;
1207
+ align-items: center;
1208
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
1209
+ padding-bottom: 10px;
1210
+ margin-bottom: 12px;
1211
+ }
1212
+ .settings-file-title {
1213
+ font-size: 1.1rem;
1214
+ font-weight: 700;
1215
+ color: var(--accent-pink);
1216
+ }
1217
+ .settings-grid {
1218
+ display: flex;
1219
+ flex-direction: column;
1220
+ gap: 12px;
1221
+ }
1222
+ .settings-field-row {
1223
+ display: flex;
1224
+ flex-direction: column;
1225
+ gap: 6px;
1226
+ }
1227
+ .settings-field-label {
1228
+ font-size: 0.8rem;
1229
+ font-weight: 600;
1230
+ color: var(--text-secondary);
1231
+ text-transform: uppercase;
1232
+ letter-spacing: 0.5px;
1233
+ }
1234
+ .settings-input {
1235
+ width: 100%;
1236
+ background: rgba(0, 0, 0, 0.2);
1237
+ border: 1px solid rgba(255, 255, 255, 0.08);
1238
+ border-radius: 8px;
1239
+ padding: 10px 12px;
1240
+ color: #fff;
1241
+ font-family: inherit;
1242
+ font-size: 0.9rem;
1243
+ outline: none;
1244
+ transition: all 0.2s;
1245
+ }
1246
+ .settings-input:focus {
1247
+ border-color: var(--accent-pink);
1248
+ box-shadow: 0 0 8px var(--glow-pink);
1249
+ }
1250
+ .settings-checkbox-container {
1251
+ display: flex;
1252
+ align-items: center;
1253
+ gap: 10px;
1254
+ cursor: pointer;
1255
+ padding: 4px 0;
1256
+ }
1257
+ .settings-checkbox-container input {
1258
+ cursor: pointer;
1259
+ width: 18px;
1260
+ height: 18px;
1261
+ accent-color: var(--accent-pink);
1262
+ }
1263
+ .settings-checkbox-label {
1264
+ font-size: 0.9rem;
1265
+ font-weight: 500;
1266
+ color: var(--text-primary);
1267
+ }
1268
+ .settings-textarea {
1269
+ width: 100%;
1270
+ background: rgba(0, 0, 0, 0.2);
1271
+ border: 1px solid rgba(255, 255, 255, 0.08);
1272
+ border-radius: 8px;
1273
+ padding: 10px 12px;
1274
+ color: #fff;
1275
+ font-family: 'Courier New', Courier, monospace;
1276
+ font-size: 0.85rem;
1277
+ line-height: 1.4;
1278
+ min-height: 120px;
1279
+ resize: vertical;
1280
+ outline: none;
1281
+ transition: all 0.2s;
1282
+ }
1283
+ .settings-textarea:focus {
1284
+ border-color: var(--accent-pink);
1285
+ box-shadow: 0 0 8px var(--glow-pink);
1286
+ }
1287
+ .btn-action {
1288
+ background: rgba(255, 255, 255, 0.05);
1289
+ border: 1px solid rgba(255, 255, 255, 0.1);
1290
+ color: #fff;
1291
+ padding: 6px 12px;
1292
+ border-radius: 8px;
1293
+ font-size: 0.85rem;
1294
+ font-weight: 600;
1295
+ cursor: pointer;
1296
+ transition: all 0.2s;
1297
+ }
1298
+ .btn-action:hover {
1299
+ background: var(--accent-pink);
1300
+ border-color: var(--accent-pink);
1301
+ box-shadow: 0 0 10px var(--glow-pink);
1302
+ }
1303
+ .btn-save-all {
1304
+ background: linear-gradient(135deg, var(--accent-pink) 0%, #a55eea 100%);
1305
+ border: none;
1306
+ color: #fff;
1307
+ padding: 8px 16px;
1308
+ border-radius: 8px;
1309
+ font-size: 0.85rem;
1310
+ font-weight: 700;
1311
+ cursor: pointer;
1312
+ transition: all 0.2s;
1313
+ }
1314
+ .btn-save-all:hover {
1315
+ transform: translateY(-1px);
1316
+ box-shadow: 0 0 15px var(--glow-pink);
1317
+ }
1318
+
1319
+ /* Toast notifications */
1320
+ .toast-container {
1321
+ position: fixed;
1322
+ bottom: 80px;
1323
+ left: 50%;
1324
+ transform: translateX(-50%) translateY(100px);
1325
+ background: rgba(26, 26, 46, 0.95);
1326
+ border: 1px solid var(--accent-pink);
1327
+ box-shadow: 0 4px 20px var(--glow-pink);
1328
+ color: #fff;
1329
+ padding: 12px 24px;
1330
+ border-radius: 30px;
1331
+ font-size: 0.9rem;
1332
+ font-weight: 600;
1333
+ z-index: 2000;
1334
+ transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
1335
+ pointer-events: none;
1336
+ }
1337
+ .toast-container.show {
1338
+ transform: translateX(-50%) translateY(0);
1339
+ }
1067
1340
  </style>
1068
1341
  </head>
1069
1342
  <body>
@@ -1092,7 +1365,8 @@
1092
1365
  </header>
1093
1366
 
1094
1367
  <main class="main">
1095
- <!-- Stats Row -->
1368
+ <div id="page-home" class="page-section">
1369
+ <!-- Stats Row -->
1096
1370
  <div class="stats-row">
1097
1371
  <div class="stat-card">
1098
1372
  <div class="stat-value" id="stat-messages">0</div>
@@ -1578,8 +1852,34 @@
1578
1852
  <span class="tendency-badge" id="tendency-badge">neutral</span>
1579
1853
  </div>
1580
1854
  </div>
1855
+ </div> <!-- #page-home -->
1856
+
1857
+ <!-- CHAT PAGE -->
1858
+ <div id="page-chat" class="page-section" style="display:none; flex-direction:column; height:100%;">
1859
+ <div id="chat-messages" class="chat-messages"></div>
1860
+ <div id="chat-typing" style="display:none; padding:8px 0; color:var(--accent-pink); font-size:0.85rem;">
1861
+ <div class="typing-indicator">
1862
+ <span>she is thinking</span>
1863
+ <div class="typing-dots">
1864
+ <span></span><span></span><span></span>
1865
+ </div>
1866
+ </div>
1867
+ </div>
1868
+ <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;">
1869
+ <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;" oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px';"></textarea>
1870
+ <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>
1871
+ </div>
1872
+ </div>
1873
+
1874
+ <!-- SETTINGS PAGE -->
1875
+ <div id="page-settings" class="page-section" style="display:none; flex-direction:column; gap:16px;">
1876
+ <div id="settings-content">Loading settings...</div>
1877
+ </div>
1581
1878
  </main>
1582
1879
 
1880
+ <!-- Toast Notification Container -->
1881
+ <div id="toast-notification" class="toast-container">Settings saved successfully</div>
1882
+
1583
1883
  <nav class="bottom-nav">
1584
1884
  <div class="nav-item active">
1585
1885
  <span class="nav-icon">🏠</span>
@@ -1603,6 +1903,291 @@
1603
1903
  'angry': '😤', 'neutral': '😌', 'calm': '😌', 'loving': '💕'
1604
1904
  };
1605
1905
 
1906
+ const pages = {
1907
+ home: 'page-home',
1908
+ chat: 'page-chat',
1909
+ settings: 'page-settings'
1910
+ };
1911
+ let activePage = 'home';
1912
+ let conversationCache = [];
1913
+ let isSettingsLoaded = false;
1914
+ let isSendingMessage = false;
1915
+
1916
+ // Tab switching
1917
+ document.addEventListener('DOMContentLoaded', () => {
1918
+ const navItems = document.querySelectorAll('.nav-item');
1919
+ const mainEl = document.querySelector('.main');
1920
+
1921
+ navItems.forEach((el, index) => {
1922
+ el.addEventListener('click', () => {
1923
+ navItems.forEach(item => item.classList.remove('active'));
1924
+ el.classList.add('active');
1925
+
1926
+ // Hide all pages
1927
+ document.getElementById('page-home').style.display = 'none';
1928
+ document.getElementById('page-chat').style.display = 'none';
1929
+ document.getElementById('page-settings').style.display = 'none';
1930
+
1931
+ // Show current page
1932
+ const pageKeys = Object.keys(pages);
1933
+ activePage = pageKeys[index];
1934
+ const activePageId = pages[activePage];
1935
+ const activeEl = document.getElementById(activePageId);
1936
+
1937
+ if (activePage === 'chat') {
1938
+ activeEl.style.display = 'flex';
1939
+ mainEl.classList.add('chat-active');
1940
+ scrollChatToBottom();
1941
+ } else {
1942
+ activeEl.style.display = 'block';
1943
+ mainEl.classList.remove('chat-active');
1944
+ }
1945
+
1946
+ if (activePage === 'settings') {
1947
+ loadSettings();
1948
+ }
1949
+ });
1950
+ });
1951
+
1952
+ // Chat input handlers
1953
+ const sendBtn = document.getElementById('chat-send');
1954
+ const inputEl = document.getElementById('chat-input');
1955
+ if (sendBtn) {
1956
+ sendBtn.addEventListener('click', sendMessage);
1957
+ }
1958
+ if (inputEl) {
1959
+ inputEl.addEventListener('keydown', (e) => {
1960
+ if (e.key === 'Enter' && !e.shiftKey) {
1961
+ e.preventDefault();
1962
+ sendMessage();
1963
+ }
1964
+ });
1965
+ }
1966
+ });
1967
+
1968
+ // Toast notifications
1969
+ function showToast(text, duration = 3000) {
1970
+ const toast = document.getElementById('toast-notification');
1971
+ toast.textContent = text;
1972
+ toast.classList.add('show');
1973
+ setTimeout(() => {
1974
+ toast.classList.remove('show');
1975
+ }, duration);
1976
+ }
1977
+
1978
+ // Chat scroll
1979
+ function scrollChatToBottom() {
1980
+ const chatMessages = document.getElementById('chat-messages');
1981
+ if (chatMessages) {
1982
+ chatMessages.scrollTop = chatMessages.scrollHeight;
1983
+ }
1984
+ }
1985
+
1986
+ // Chat rendering
1987
+ function updateChatUI(conversation, isThinking) {
1988
+ const messagesContainer = document.getElementById('chat-messages');
1989
+ if (!messagesContainer) return;
1990
+
1991
+ // Check if conversation changed to avoid redrawing if same
1992
+ const cacheStr = JSON.stringify(conversation);
1993
+ const prevStr = JSON.stringify(conversationCache);
1994
+
1995
+ if (cacheStr !== prevStr) {
1996
+ conversationCache = conversation;
1997
+
1998
+ if (!conversation || conversation.length === 0) {
1999
+ messagesContainer.innerHTML = '<div style="text-align:center;color:var(--text-muted);margin-top:40px;font-size:0.9rem;">No messages yet. Say hi!</div>';
2000
+ } else {
2001
+ messagesContainer.innerHTML = conversation.map(msg => {
2002
+ const isUser = msg.role === 'user';
2003
+ const time = msg.time || '';
2004
+ return `<div class="chat-msg-row ${isUser ? 'user' : 'alive-ai'}">
2005
+ <div class="chat-bubble">
2006
+ <div>${escapeHtml(msg.content)}</div>
2007
+ <span class="chat-bubble-time">${time}</span>
2008
+ </div>
2009
+ </div>`;
2010
+ }).join('');
2011
+ }
2012
+ scrollChatToBottom();
2013
+ }
2014
+
2015
+ // Show/hide thinking indicator
2016
+ const typingEl = document.getElementById('chat-typing');
2017
+ if (typingEl) {
2018
+ typingEl.style.display = isThinking ? 'block' : 'none';
2019
+ if (isThinking) {
2020
+ scrollChatToBottom();
2021
+ }
2022
+ }
2023
+ }
2024
+
2025
+ // Send message via API
2026
+ async function sendMessage() {
2027
+ const inputEl = document.getElementById('chat-input');
2028
+ const btnEl = document.getElementById('chat-send');
2029
+ if (!inputEl || !btnEl || isSendingMessage) return;
2030
+
2031
+ const text = inputEl.value.trim();
2032
+ if (!text) return;
2033
+
2034
+ isSendingMessage = true;
2035
+ inputEl.disabled = true;
2036
+ btnEl.style.opacity = '0.5';
2037
+
2038
+ try {
2039
+ const response = await fetch('/api/chat', {
2040
+ method: 'POST',
2041
+ headers: { 'Content-Type': 'application/json' },
2042
+ body: JSON.stringify({ text })
2043
+ });
2044
+ const resData = await response.json();
2045
+ if (response.ok && resData.status === 'sent') {
2046
+ inputEl.value = '';
2047
+ inputEl.style.height = 'auto'; // reset textarea height
2048
+ } else {
2049
+ showToast('Failed to send: ' + (resData.message || 'Unknown error'));
2050
+ }
2051
+ } catch (err) {
2052
+ console.error('Error sending message:', err);
2053
+ showToast('Failed to send message');
2054
+ } finally {
2055
+ isSendingMessage = false;
2056
+ inputEl.disabled = false;
2057
+ btnEl.style.opacity = '1';
2058
+ inputEl.focus();
2059
+ }
2060
+ }
2061
+
2062
+ // Settings load
2063
+ async function loadSettings() {
2064
+ const container = document.getElementById('settings-content');
2065
+ if (!container) return;
2066
+
2067
+ try {
2068
+ container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:20px;">Loading settings...</div>';
2069
+ const response = await fetch('/api/settings');
2070
+ if (!response.ok) throw new Error('HTTP ' + response.status);
2071
+ const settingsData = await response.json();
2072
+ renderSettings(settingsData);
2073
+ isSettingsLoaded = true;
2074
+ } catch (err) {
2075
+ console.error('Error loading settings:', err);
2076
+ container.innerHTML = `<div style="text-align:center;color:#ff4d4d;padding:20px;">Failed to load settings: ${escapeHtml(err.message)}</div>`;
2077
+ }
2078
+ }
2079
+
2080
+ // Settings rendering
2081
+ function renderSettings(settingsData) {
2082
+ const container = document.getElementById('settings-content');
2083
+ if (!container) return;
2084
+
2085
+ let html = '';
2086
+ Object.entries(settingsData).forEach(([filename, fileData]) => {
2087
+ let contentStr = '';
2088
+ if (fileData.type === 'json') {
2089
+ contentStr = JSON.stringify(fileData.content, null, 2);
2090
+ } else {
2091
+ contentStr = fileData.content;
2092
+ }
2093
+
2094
+ html += `
2095
+ <div class="section settings-card">
2096
+ <div class="settings-header-btn-row">
2097
+ <div class="settings-file-title">⚙️ ${filename}</div>
2098
+ <button class="btn-save btn-action" data-file="${filename}" onclick="saveFileSettings('${filename}')">Save</button>
2099
+ </div>
2100
+ <div class="settings-grid">
2101
+ <textarea class="settings-textarea" data-file="${filename}" oninput="validateJSONInput(this, '${filename}')" spellcheck="false">${escapeHtml(contentStr)}</textarea>
2102
+ </div>
2103
+ </div>
2104
+ `;
2105
+ });
2106
+ container.innerHTML = html;
2107
+ }
2108
+
2109
+ // Real-time JSON validation
2110
+ function validateJSONInput(textarea, filename) {
2111
+ const btn = document.querySelector(`.btn-save[data-file="${filename}"]`);
2112
+ let errorEl = textarea.parentNode.querySelector('.json-error-msg');
2113
+
2114
+ if (!errorEl) {
2115
+ errorEl = document.createElement('div');
2116
+ errorEl.className = 'json-error-msg';
2117
+ errorEl.style.color = '#ff6b9d';
2118
+ errorEl.style.fontSize = '0.8rem';
2119
+ errorEl.style.marginTop = '4px';
2120
+ textarea.parentNode.appendChild(errorEl);
2121
+ }
2122
+
2123
+ if (!filename.endsWith('.json')) {
2124
+ errorEl.textContent = '';
2125
+ return true;
2126
+ }
2127
+
2128
+ try {
2129
+ JSON.parse(textarea.value);
2130
+ textarea.style.borderColor = 'rgba(255, 255, 255, 0.1)';
2131
+ errorEl.textContent = '';
2132
+ if (btn) btn.disabled = false;
2133
+ return true;
2134
+ } catch (e) {
2135
+ textarea.style.borderColor = '#ff6b9d';
2136
+ errorEl.textContent = 'Invalid JSON: ' + e.message;
2137
+ if (btn) btn.disabled = true;
2138
+ return false;
2139
+ }
2140
+ }
2141
+
2142
+ // Settings saving
2143
+ async function saveFileSettings(filename) {
2144
+ const textarea = document.querySelector(`.settings-textarea[data-file="${filename}"]`);
2145
+ if (!textarea) return;
2146
+
2147
+ let parsedContent;
2148
+ if (filename.endsWith('.json')) {
2149
+ try {
2150
+ parsedContent = JSON.parse(textarea.value);
2151
+ } catch (e) {
2152
+ showToast(`Invalid JSON in ${filename}: ${e.message}`);
2153
+ return;
2154
+ }
2155
+ } else {
2156
+ parsedContent = textarea.value;
2157
+ }
2158
+
2159
+ const btn = document.querySelector(`.btn-save[data-file="${filename}"]`);
2160
+ if (btn) {
2161
+ btn.disabled = true;
2162
+ btn.textContent = 'Saving...';
2163
+ }
2164
+
2165
+ try {
2166
+ const response = await fetch('/api/settings', {
2167
+ method: 'POST',
2168
+ headers: { 'Content-Type': 'application/json' },
2169
+ body: JSON.stringify({
2170
+ file: filename,
2171
+ content: parsedContent
2172
+ })
2173
+ });
2174
+ const resData = await response.json();
2175
+ if (response.ok && resData.status === 'saved') {
2176
+ showToast(`${filename} saved successfully!`);
2177
+ } else {
2178
+ showToast(`Failed to save: ${resData.message || 'Unknown error'}`);
2179
+ }
2180
+ } catch (err) {
2181
+ console.error('Error saving settings:', err);
2182
+ showToast(`Error saving ${filename}`);
2183
+ } finally {
2184
+ if (btn) {
2185
+ btn.disabled = false;
2186
+ btn.textContent = 'Save';
2187
+ }
2188
+ }
2189
+ }
2190
+
1606
2191
  let eventSource;
1607
2192
  let startTime = null; // Will be set from server's start_time
1608
2193
  const banner = document.getElementById('connection-banner');
@@ -1628,6 +2213,9 @@
1628
2213
  }
1629
2214
 
1630
2215
  function updateUI(data) {
2216
+ // Update Chat UI
2217
+ updateChatUI(data.conversation || [], !!data.thinking);
2218
+
1631
2219
  // Mood
1632
2220
  const mood = data.mood || 'neutral';
1633
2221
  document.getElementById('mood-emoji').textContent = moodEmojis[mood] || '😌';
@@ -1809,17 +2397,20 @@
1809
2397
  const name = c.name || c.id || 'Unknown';
1810
2398
  const desire = c.desire || c.side_a || '';
1811
2399
  const fear = c.fear || c.side_b || '';
1812
- const tension = Math.round((c.tension_level || c.intensity || 0) * 100);
2400
+ // Support both 'tension' (inconsistency API) and 'tension_level' (soul API)
2401
+ const rawTension = c.tension_level !== undefined ? c.tension_level : (c.tension !== undefined ? c.tension : c.intensity || 0);
2402
+ const tension = Math.round(rawTension * 100);
1813
2403
 
1814
2404
  return `
1815
2405
  <div class="conflict-item">
1816
2406
  <div class="conflict-name">${escapeHtml(name.replace(/_/g, ' '))}</div>
1817
2407
  <div class="conflict-detail">${escapeHtml(desire)} <span style="color:var(--text-muted)">vs</span> ${escapeHtml(fear)}</div>
1818
2408
  <div class="conflict-tension" style="margin-top:4px;height:3px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;">
1819
- <div style="width:${tension}%;height:100%;background:linear-gradient(90deg,#e74c3c,#ff9800);border-radius:2px;"></div>
2409
+ <div style="width:${tension}%;height:100%;background:linear-gradient(90deg,#e74c3c,#ff9800);border-radius:2px;transition:width 0.6s ease;"></div>
1820
2410
  </div>
1821
2411
  </div>
1822
2412
  `}).join('');
2413
+
1823
2414
  } else if (conflictsEl) {
1824
2415
  conflictsEl.innerHTML = '<div class="conflict-empty">No active internal conflicts</div>';
1825
2416
  }
@@ -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
+ }