@innvisor/conny-ai 9.7.0

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.
Files changed (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. package/verify_conversation_impl.py +48 -0
@@ -0,0 +1,1379 @@
1
+ """
2
+ Conny Admin API — Multi-tenant runtime configuration endpoints.
3
+
4
+ Mounted at /admin on the main FastAPI app.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sqlite3
10
+ import logging
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+ from typing import List, Optional
14
+
15
+ from fastapi import APIRouter, Header, HTTPException, Request
16
+ from fastapi.responses import HTMLResponse
17
+ from pydantic import BaseModel, Field
18
+
19
+ log = logging.getLogger("conny.admin_api")
20
+
21
+ router = APIRouter(prefix="/admin", tags=["admin"])
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Auth
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def _get_admin_key() -> str:
28
+ return os.environ.get("ADMIN_API_KEY") or os.environ.get("MASTER_API_KEY", "")
29
+
30
+
31
+ def _verify_auth(x_admin_key: Optional[str] = None, request: Optional[Request] = None):
32
+ expected = _get_admin_key()
33
+ if not expected:
34
+ raise HTTPException(status_code=500, detail="ADMIN_API_KEY not configured")
35
+
36
+ token = x_admin_key
37
+ if not token and request:
38
+ token = request.query_params.get("key")
39
+
40
+ if token != expected:
41
+ raise HTTPException(status_code=401, detail="Invalid admin key")
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Request/Response models
46
+ # ---------------------------------------------------------------------------
47
+
48
+ class PersonaUpdate(BaseModel):
49
+ tone: str = "professional"
50
+ verbosity: str = "concise"
51
+ greeting_style: str = "warm"
52
+ sign_off: str = ""
53
+ forbidden_topics: List[str] = Field(default_factory=list)
54
+ escalation_phrases: List[str] = Field(default_factory=list)
55
+
56
+
57
+ class ModelUpdate(BaseModel):
58
+ provider: str = "anthropic"
59
+ model_id: str = "claude-sonnet-4-20250514"
60
+ temperature: float = 0.7
61
+ max_tokens: int = 1024
62
+ thinking_budget: int = 0
63
+
64
+
65
+ class TeachRequest(BaseModel):
66
+ question: str
67
+ answer: str
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Helpers
72
+ # ---------------------------------------------------------------------------
73
+
74
+ _BASE_DIR = Path(__file__).resolve().parent
75
+ _PERSONAS_DIR = _BASE_DIR / "personas"
76
+ _MODEL_CONFIG_DIR = _BASE_DIR / "model_configs"
77
+ _KNOWLEDGE_GAPS_DIR = _BASE_DIR / "knowledge_gaps"
78
+ _TEACHINGS_DIR = _BASE_DIR / "teachings"
79
+
80
+
81
+ def _read_jsonl(path: Path, limit: int = 100) -> list:
82
+ """Read last N lines from a JSONL file."""
83
+ if not path.exists():
84
+ return []
85
+ lines = path.read_text().strip().splitlines()
86
+ entries = []
87
+ for line in lines[-limit:]:
88
+ try:
89
+ entries.append(json.loads(line))
90
+ except json.JSONDecodeError:
91
+ continue
92
+ return entries
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Endpoints
97
+ # ---------------------------------------------------------------------------
98
+
99
+ @router.post("/{instance_id}/persona")
100
+ async def update_persona(
101
+ instance_id: str,
102
+ body: PersonaUpdate,
103
+ x_admin_key: Optional[str] = Header(None),
104
+ ):
105
+ """Update personality/persona at runtime for a given instance."""
106
+ _verify_auth(x_admin_key)
107
+
108
+ persona_dir = _PERSONAS_DIR / instance_id
109
+ persona_dir.mkdir(parents=True, exist_ok=True)
110
+
111
+ override_path = persona_dir / "runtime_override.json"
112
+ payload = body.model_dump()
113
+ payload["updated_at"] = datetime.now().isoformat()
114
+
115
+ override_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
116
+ log.info(f"[admin] persona updated for {instance_id}")
117
+
118
+ return {"ok": True, "applied": payload}
119
+
120
+
121
+ @router.post("/{instance_id}/model")
122
+ async def update_model(
123
+ instance_id: str,
124
+ body: ModelUpdate,
125
+ x_admin_key: Optional[str] = Header(None),
126
+ ):
127
+ """Change LLM provider/model configuration at runtime."""
128
+ _verify_auth(x_admin_key)
129
+
130
+ _MODEL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
131
+ config_path = _MODEL_CONFIG_DIR / f"{instance_id}.json"
132
+
133
+ payload = body.model_dump()
134
+ payload["updated_at"] = datetime.now().isoformat()
135
+
136
+ config_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
137
+ log.info(f"[admin] model config updated for {instance_id}: {body.provider}/{body.model_id}")
138
+
139
+ return {"ok": True, "model": payload}
140
+
141
+
142
+ @router.get("/{instance_id}/status")
143
+ async def get_status(
144
+ instance_id: str,
145
+ x_admin_key: Optional[str] = Header(None),
146
+ ):
147
+ """Get current config and recent knowledge gaps for an instance."""
148
+ _verify_auth(x_admin_key)
149
+
150
+ # Read persona override
151
+ persona_path = _PERSONAS_DIR / instance_id / "runtime_override.json"
152
+ persona = {}
153
+ if persona_path.exists():
154
+ try:
155
+ persona = json.loads(persona_path.read_text())
156
+ except json.JSONDecodeError:
157
+ persona = {"error": "corrupt persona file"}
158
+
159
+ # Read model config
160
+ model_path = _MODEL_CONFIG_DIR / f"{instance_id}.json"
161
+ model = {}
162
+ if model_path.exists():
163
+ try:
164
+ model = json.loads(model_path.read_text())
165
+ except json.JSONDecodeError:
166
+ model = {"error": "corrupt model config file"}
167
+
168
+ # Read recent gaps
169
+ today = datetime.now().strftime("%Y-%m-%d")
170
+ gap_file = _KNOWLEDGE_GAPS_DIR / f"{today}.jsonl"
171
+ all_gaps = _read_jsonl(gap_file, limit=200)
172
+ instance_gaps = [g for g in all_gaps if g.get("instance_id") == instance_id][-10:]
173
+
174
+ return {
175
+ "instance_id": instance_id,
176
+ "persona": persona,
177
+ "model": model,
178
+ "recent_gaps": instance_gaps,
179
+ "gaps_today": len(instance_gaps),
180
+ }
181
+
182
+
183
+ @router.get("/{instance_id}/gaps")
184
+ async def get_gaps(
185
+ instance_id: str,
186
+ x_admin_key: Optional[str] = Header(None),
187
+ limit: int = 50,
188
+ ):
189
+ """Get knowledge gaps log for an instance."""
190
+ _verify_auth(x_admin_key)
191
+
192
+ _KNOWLEDGE_GAPS_DIR.mkdir(parents=True, exist_ok=True)
193
+
194
+ # Collect from recent JSONL files
195
+ gap_files = sorted(_KNOWLEDGE_GAPS_DIR.glob("*.jsonl"), reverse=True)[:7]
196
+ all_gaps: list = []
197
+
198
+ for gf in gap_files:
199
+ entries = _read_jsonl(gf, limit=500)
200
+ instance_entries = [e for e in entries if e.get("instance_id") == instance_id]
201
+ all_gaps.extend(instance_entries)
202
+ if len(all_gaps) >= limit:
203
+ break
204
+
205
+ all_gaps = all_gaps[:limit]
206
+
207
+ return {
208
+ "instance_id": instance_id,
209
+ "total": len(all_gaps),
210
+ "gaps": all_gaps,
211
+ }
212
+
213
+
214
+ @router.post("/{instance_id}/teach")
215
+ async def teach_fact(
216
+ instance_id: str,
217
+ body: TeachRequest,
218
+ x_admin_key: Optional[str] = Header(None),
219
+ ):
220
+ """Admin teaches Conny a new fact (question/answer pair)."""
221
+ _verify_auth(x_admin_key)
222
+
223
+ _TEACHINGS_DIR.mkdir(parents=True, exist_ok=True)
224
+ teachings_file = _TEACHINGS_DIR / f"{instance_id}.jsonl"
225
+
226
+ entry = {
227
+ "ts": datetime.now().isoformat(),
228
+ "question": body.question,
229
+ "answer": body.answer,
230
+ }
231
+
232
+ with open(teachings_file, "a") as f:
233
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
234
+
235
+ log.info(f"[admin] new teaching for {instance_id}: Q='{body.question[:60]}'")
236
+
237
+ return {
238
+ "ok": True,
239
+ "instance_id": instance_id,
240
+ "taught": entry,
241
+ }
242
+
243
+
244
+ # ===========================================================================
245
+ # Real-Time Dashboard and Chat retrieval features
246
+ # ===========================================================================
247
+
248
+ def _get_db_path(instance_id: str) -> Path:
249
+ """Robustly resolve SQLite database path for client sub-instances and main base dir."""
250
+ instances_dir = Path("/home/ubuntu/conny-instances")
251
+ instance_db = instances_dir / instance_id / "conny.db"
252
+ if instance_db.exists():
253
+ return instance_db
254
+
255
+ base_dir = Path("/home/ubuntu/conny")
256
+ ultra_db = base_dir / "conny_ultra.db"
257
+ if ultra_db.exists():
258
+ return ultra_db
259
+
260
+ return base_dir / "conny_ultra.db"
261
+
262
+
263
+ def _query_db(db_path: Path, query: str, args: tuple = (), one: bool = False):
264
+ """Run safety query against the SQLite database."""
265
+ if not db_path.exists():
266
+ return [] if not one else None
267
+ conn = sqlite3.connect(str(db_path))
268
+ conn.row_factory = sqlite3.Row
269
+ cur = conn.cursor()
270
+ try:
271
+ cur.execute(query, args)
272
+ rv = cur.fetchall()
273
+ conn.commit()
274
+ return (rv[0] if rv else None) if one else rv
275
+ except Exception as e:
276
+ log.error(f"[admin_api] DB Query Error: {e}")
277
+ raise HTTPException(status_code=500, detail=f"Database query error: {str(e)}")
278
+ finally:
279
+ conn.close()
280
+
281
+
282
+ @router.get("/{instance_id}/chats")
283
+ async def list_chats(
284
+ instance_id: str,
285
+ request: Request,
286
+ x_admin_key: Optional[str] = Header(None),
287
+ ):
288
+ """Retrieve all active conversations and their latest messages for the sidebar."""
289
+ _verify_auth(x_admin_key, request)
290
+ db_path = _get_db_path(instance_id)
291
+
292
+ query = """
293
+ SELECT c.chat_id, c.role, c.content, c.model_used, c.tokens_used, c.latency_ms, c.ts
294
+ FROM conversations c
295
+ INNER JOIN (
296
+ SELECT chat_id, MAX(id) as max_id
297
+ FROM conversations
298
+ GROUP BY chat_id
299
+ ) latest ON c.id = latest.max_id
300
+ ORDER BY c.id DESC;
301
+ """
302
+ rows = _query_db(db_path, query)
303
+
304
+ count_query = "SELECT chat_id, COUNT(*) as msg_count FROM conversations GROUP BY chat_id;"
305
+ counts = {r["chat_id"]: r["msg_count"] for r in _query_db(db_path, count_query)}
306
+
307
+ chats = []
308
+ for r in rows:
309
+ chats.append({
310
+ "chat_id": r["chat_id"],
311
+ "last_role": r["role"],
312
+ "last_content": r["content"],
313
+ "model_used": r["model_used"],
314
+ "tokens_used": r["tokens_used"],
315
+ "latency_ms": r["latency_ms"],
316
+ "ts": r["ts"],
317
+ "msg_count": counts.get(r["chat_id"], 0)
318
+ })
319
+
320
+ return {"chats": chats}
321
+
322
+
323
+ @router.get("/{instance_id}/chats/{chat_id}")
324
+ async def get_chat_details(
325
+ instance_id: str,
326
+ chat_id: str,
327
+ request: Request,
328
+ x_admin_key: Optional[str] = Header(None),
329
+ ):
330
+ """Retrieve chronological turn-by-turn history of a specific conversation."""
331
+ _verify_auth(x_admin_key, request)
332
+ db_path = _get_db_path(instance_id)
333
+
334
+ query = """
335
+ SELECT id, role, content, analysis, tokens_used, model_used, latency_ms, ts
336
+ FROM conversations
337
+ WHERE chat_id = ?
338
+ ORDER BY id ASC;
339
+ """
340
+ rows = _query_db(db_path, query, (chat_id,))
341
+
342
+ messages = []
343
+ for r in rows:
344
+ analysis_data = {}
345
+ if r["analysis"]:
346
+ try:
347
+ analysis_data = json.loads(r["analysis"])
348
+ except Exception:
349
+ analysis_data = {"raw": r["analysis"]}
350
+
351
+ messages.append({
352
+ "id": r["id"],
353
+ "role": r["role"],
354
+ "content": r["content"],
355
+ "analysis": analysis_data,
356
+ "tokens_used": r["tokens_used"],
357
+ "model_used": r["model_used"],
358
+ "latency_ms": r["latency_ms"],
359
+ "ts": r["ts"],
360
+ })
361
+
362
+ return {
363
+ "chat_id": chat_id,
364
+ "messages": messages,
365
+ }
366
+
367
+
368
+ @router.get("/{instance_id}/dashboard", response_class=HTMLResponse)
369
+ async def serve_dashboard(
370
+ instance_id: str,
371
+ ):
372
+ """Serve the premium, real-time WhatsApp-styled chat dashboard."""
373
+ html_content = f"""<!DOCTYPE html>
374
+ <html lang="es">
375
+ <head>
376
+ <meta charset="UTF-8">
377
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
378
+ <title>Conny Dashboard — Live Streams</title>
379
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
380
+ <style>
381
+ :root {{
382
+ --accent: #00a884;
383
+ --accent-hover: #008f72;
384
+ --transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
385
+
386
+ /* Dark Theme (Default) */
387
+ --bg-main: #0b141a;
388
+ --bg-sidebar: #111b21;
389
+ --bg-header: #202c33;
390
+ --bg-active: #2a3942;
391
+ --bg-hover: #202c33;
392
+ --text-primary: #e9edef;
393
+ --text-secondary: #8696a0;
394
+ --bubble-user: #005c4b;
395
+ --bubble-conny: #202c33;
396
+ --border: #222d34;
397
+ --modal-bg: #222e35;
398
+ --input-bg: #2a3942;
399
+ --shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
400
+ }}
401
+
402
+ body.light-theme {{
403
+ /* Light Theme */
404
+ --bg-main: #efeae2;
405
+ --bg-sidebar: #ffffff;
406
+ --bg-header: #f0f2f5;
407
+ --bg-active: #ebebeb;
408
+ --bg-hover: #f5f6f6;
409
+ --text-primary: #111b21;
410
+ --text-secondary: #667781;
411
+ --bubble-user: #d9fdd3;
412
+ --bubble-conny: #ffffff;
413
+ --border: #e9edef;
414
+ --modal-bg: #ffffff;
415
+ --input-bg: #f0f2f5;
416
+ --shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
417
+ }}
418
+
419
+ * {{
420
+ margin: 0;
421
+ padding: 0;
422
+ box-sizing: border-box;
423
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
424
+ }}
425
+
426
+ body {{
427
+ background-color: var(--bg-main);
428
+ color: var(--text-primary);
429
+ height: 100vh;
430
+ display: flex;
431
+ overflow: hidden;
432
+ transition: var(--transition);
433
+ }}
434
+
435
+ /* Layout Structure */
436
+ .sidebar {{
437
+ width: 380px;
438
+ min-width: 320px;
439
+ background-color: var(--bg-sidebar);
440
+ border-right: 1px solid var(--border);
441
+ display: flex;
442
+ flex-direction: column;
443
+ height: 100%;
444
+ z-index: 10;
445
+ }}
446
+
447
+ .main-chat {{
448
+ flex: 1;
449
+ display: flex;
450
+ flex-direction: column;
451
+ height: 100%;
452
+ background-color: var(--bg-main);
453
+ position: relative;
454
+ }}
455
+
456
+ /* Headers styling */
457
+ .header {{
458
+ height: 64px;
459
+ background-color: var(--bg-header);
460
+ padding: 10px 16px;
461
+ display: flex;
462
+ align-items: center;
463
+ justify-content: space-between;
464
+ border-bottom: 1px solid var(--border);
465
+ }}
466
+
467
+ .brand-section {{
468
+ display: flex;
469
+ align-items: center;
470
+ gap: 12px;
471
+ }}
472
+
473
+ .live-indicator {{
474
+ display: inline-block;
475
+ width: 9px;
476
+ height: 9px;
477
+ background-color: var(--accent);
478
+ border-radius: 50%;
479
+ position: relative;
480
+ box-shadow: 0 0 0 rgba(0, 168, 132, 0.4);
481
+ animation: pulse-green 2s infinite;
482
+ }}
483
+
484
+ @keyframes pulse-green {{
485
+ 0% {{
486
+ transform: scale(0.95);
487
+ box-shadow: 0 0 0 0 rgba(0, 168, 132, 0.7);
488
+ }}
489
+ 70% {{
490
+ transform: scale(1);
491
+ box-shadow: 0 0 0 6px rgba(0, 168, 132, 0);
492
+ }}
493
+ 100% {{
494
+ transform: scale(0.95);
495
+ box-shadow: 0 0 0 0 rgba(0, 168, 132, 0);
496
+ }}
497
+ }}
498
+
499
+ .brand-title {{
500
+ font-size: 1.1rem;
501
+ font-weight: 700;
502
+ color: var(--text-primary);
503
+ letter-spacing: -0.02em;
504
+ }}
505
+
506
+ .theme-toggle {{
507
+ background: none;
508
+ border: none;
509
+ color: var(--text-secondary);
510
+ font-size: 1.25rem;
511
+ cursor: pointer;
512
+ width: 36px;
513
+ height: 36px;
514
+ border-radius: 50%;
515
+ display: flex;
516
+ align-items: center;
517
+ justify-content: center;
518
+ transition: var(--transition);
519
+ }}
520
+
521
+ .theme-toggle:hover {{
522
+ background-color: var(--bg-hover);
523
+ color: var(--text-primary);
524
+ }}
525
+
526
+ /* Search area */
527
+ .search-container {{
528
+ padding: 8px 14px;
529
+ border-bottom: 1px solid var(--border);
530
+ }}
531
+
532
+ .search-box {{
533
+ width: 100%;
534
+ background-color: var(--input-bg);
535
+ border: none;
536
+ border-radius: 8px;
537
+ padding: 8px 12px 8px 36px;
538
+ font-size: 0.9rem;
539
+ color: var(--text-primary);
540
+ outline: none;
541
+ position: relative;
542
+ }}
543
+
544
+ .search-wrapper {{
545
+ position: relative;
546
+ }}
547
+
548
+ .search-icon {{
549
+ position: absolute;
550
+ left: 12px;
551
+ top: 50%;
552
+ transform: translateY(-50%);
553
+ color: var(--text-secondary);
554
+ font-size: 0.95rem;
555
+ pointer-events: none;
556
+ }}
557
+
558
+ /* Chat list */
559
+ .chat-list {{
560
+ flex: 1;
561
+ overflow-y: auto;
562
+ }}
563
+
564
+ .chat-item {{
565
+ display: flex;
566
+ padding: 12px 16px;
567
+ gap: 12px;
568
+ cursor: pointer;
569
+ border-bottom: 1px solid var(--border);
570
+ transition: var(--transition);
571
+ }}
572
+
573
+ .chat-item:hover {{
574
+ background-color: var(--bg-hover);
575
+ }}
576
+
577
+ .chat-item.active {{
578
+ background-color: var(--bg-active);
579
+ }}
580
+
581
+ .avatar {{
582
+ width: 48px;
583
+ height: 48px;
584
+ border-radius: 50%;
585
+ background: linear-gradient(135deg, #00a884, #056162);
586
+ color: white;
587
+ font-weight: 600;
588
+ display: flex;
589
+ align-items: center;
590
+ justify-content: center;
591
+ font-size: 1.15rem;
592
+ flex-shrink: 0;
593
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
594
+ }}
595
+
596
+ .chat-info {{
597
+ flex: 1;
598
+ min-width: 0;
599
+ display: flex;
600
+ flex-direction: column;
601
+ justify-content: center;
602
+ gap: 4px;
603
+ }}
604
+
605
+ .chat-top {{
606
+ display: flex;
607
+ justify-content: space-between;
608
+ align-items: center;
609
+ }}
610
+
611
+ .chat-name {{
612
+ font-weight: 600;
613
+ font-size: 0.95rem;
614
+ color: var(--text-primary);
615
+ white-space: nowrap;
616
+ overflow: hidden;
617
+ text-overflow: ellipsis;
618
+ }}
619
+
620
+ .chat-time {{
621
+ font-size: 0.75rem;
622
+ color: var(--text-secondary);
623
+ flex-shrink: 0;
624
+ }}
625
+
626
+ .chat-bottom {{
627
+ display: flex;
628
+ justify-content: space-between;
629
+ align-items: center;
630
+ }}
631
+
632
+ .chat-preview {{
633
+ font-size: 0.82rem;
634
+ color: var(--text-secondary);
635
+ white-space: nowrap;
636
+ overflow: hidden;
637
+ text-overflow: ellipsis;
638
+ flex: 1;
639
+ margin-right: 8px;
640
+ }}
641
+
642
+ .badge {{
643
+ background-color: var(--accent);
644
+ color: white;
645
+ font-size: 0.7rem;
646
+ font-weight: 700;
647
+ padding: 2px 6px;
648
+ border-radius: 10px;
649
+ min-width: 18px;
650
+ text-align: center;
651
+ }}
652
+
653
+ /* Chat window pane */
654
+ .chat-pane {{
655
+ flex: 1;
656
+ display: flex;
657
+ flex-direction: column;
658
+ background-image: radial-gradient(var(--border) 1px, transparent 0);
659
+ background-size: 24px 24px;
660
+ overflow-y: auto;
661
+ padding: 24px;
662
+ gap: 16px;
663
+ }}
664
+
665
+ .splash-screen {{
666
+ flex: 1;
667
+ display: flex;
668
+ flex-direction: column;
669
+ align-items: center;
670
+ justify-content: center;
671
+ background-color: var(--bg-main);
672
+ color: var(--text-secondary);
673
+ gap: 16px;
674
+ padding: 40px;
675
+ text-align: center;
676
+ }}
677
+
678
+ .splash-logo {{
679
+ width: 80px;
680
+ height: 80px;
681
+ border-radius: 50%;
682
+ background: linear-gradient(135deg, var(--accent), #056162);
683
+ color: white;
684
+ font-size: 2.2rem;
685
+ font-weight: 800;
686
+ display: flex;
687
+ align-items: center;
688
+ justify-content: center;
689
+ box-shadow: var(--shadow);
690
+ margin-bottom: 8px;
691
+ }}
692
+
693
+ /* Message bubbles */
694
+ .message-row {{
695
+ display: flex;
696
+ width: 100%;
697
+ margin-bottom: 4px;
698
+ }}
699
+
700
+ .message-row.user-row {{
701
+ justify-content: flex-end;
702
+ }}
703
+
704
+ .message-row.conny-row {{
705
+ justify-content: flex-start;
706
+ }}
707
+
708
+ .bubble {{
709
+ max-width: 65%;
710
+ padding: 8px 12px 6px 12px;
711
+ border-radius: 8px;
712
+ font-size: 0.92rem;
713
+ line-height: 1.45;
714
+ position: relative;
715
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
716
+ animation: fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
717
+ }}
718
+
719
+ @keyframes fadeIn {{
720
+ from {{ opacity: 0; transform: translateY(4px); }}
721
+ to {{ opacity: 1; transform: translateY(0); }}
722
+ }}
723
+
724
+ .user-row .bubble {{
725
+ background-color: var(--bubble-user);
726
+ color: var(--text-primary);
727
+ border-top-right-radius: 0;
728
+ }}
729
+
730
+ .conny-row .bubble {{
731
+ background-color: var(--bubble-conny);
732
+ color: var(--text-primary);
733
+ border-top-left-radius: 0;
734
+ border: 1px solid var(--border);
735
+ }}
736
+
737
+ .bubble-text {{
738
+ word-wrap: break-word;
739
+ white-space: pre-wrap;
740
+ }}
741
+
742
+ .bubble-meta {{
743
+ display: flex;
744
+ justify-content: flex-end;
745
+ align-items: center;
746
+ gap: 4px;
747
+ font-size: 0.68rem;
748
+ color: var(--text-secondary);
749
+ margin-top: 4px;
750
+ user-select: none;
751
+ }}
752
+
753
+ /* Insights panel under Conny bubbles */
754
+ .insights-container {{
755
+ width: 100%;
756
+ display: flex;
757
+ flex-direction: column;
758
+ gap: 4px;
759
+ margin-top: -6px;
760
+ margin-bottom: 12px;
761
+ padding-left: 12px;
762
+ }}
763
+
764
+ .insights-row {{
765
+ display: flex;
766
+ flex-wrap: wrap;
767
+ gap: 6px;
768
+ align-items: center;
769
+ }}
770
+
771
+ .pill {{
772
+ font-size: 0.72rem;
773
+ font-weight: 500;
774
+ background-color: var(--bg-header);
775
+ border: 1px solid var(--border);
776
+ color: var(--text-secondary);
777
+ padding: 2px 8px;
778
+ border-radius: 12px;
779
+ display: flex;
780
+ align-items: center;
781
+ gap: 4px;
782
+ user-select: none;
783
+ transition: var(--transition);
784
+ }}
785
+
786
+ .pill-interactive {{
787
+ cursor: pointer;
788
+ }}
789
+
790
+ .pill-interactive:hover {{
791
+ background-color: var(--bg-active);
792
+ color: var(--text-primary);
793
+ border-color: var(--accent);
794
+ }}
795
+
796
+ /* Modals and forms */
797
+ .modal-overlay {{
798
+ position: fixed;
799
+ top: 0;
800
+ left: 0;
801
+ width: 100%;
802
+ height: 100%;
803
+ background: rgba(0, 0, 0, 0.65);
804
+ backdrop-filter: blur(4px);
805
+ z-index: 100;
806
+ display: flex;
807
+ align-items: center;
808
+ justify-content: center;
809
+ opacity: 0;
810
+ pointer-events: none;
811
+ transition: opacity 0.25s ease;
812
+ }}
813
+
814
+ .modal-overlay.active {{
815
+ opacity: 1;
816
+ pointer-events: auto;
817
+ }}
818
+
819
+ .modal-content {{
820
+ background-color: var(--modal-bg);
821
+ width: 650px;
822
+ max-width: 90%;
823
+ max-height: 80vh;
824
+ border-radius: 12px;
825
+ box-shadow: var(--shadow);
826
+ border: 1px solid var(--border);
827
+ display: flex;
828
+ flex-direction: column;
829
+ overflow: hidden;
830
+ transform: scale(0.95);
831
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
832
+ }}
833
+
834
+ .modal-overlay.active .modal-content {{
835
+ transform: scale(1);
836
+ }}
837
+
838
+ .modal-header {{
839
+ padding: 16px 20px;
840
+ border-bottom: 1px solid var(--border);
841
+ display: flex;
842
+ justify-content: space-between;
843
+ align-items: center;
844
+ }}
845
+
846
+ .modal-title {{
847
+ font-size: 1.15rem;
848
+ font-weight: 700;
849
+ }}
850
+
851
+ .modal-close {{
852
+ background: none;
853
+ border: none;
854
+ font-size: 1.5rem;
855
+ color: var(--text-secondary);
856
+ cursor: pointer;
857
+ transition: var(--transition);
858
+ }}
859
+
860
+ .modal-close:hover {{
861
+ color: var(--text-primary);
862
+ }}
863
+
864
+ .modal-body {{
865
+ padding: 20px;
866
+ overflow-y: auto;
867
+ flex: 1;
868
+ }}
869
+
870
+ .json-viewer {{
871
+ background-color: #0d1117;
872
+ color: #c9d1d9;
873
+ padding: 14px;
874
+ border-radius: 8px;
875
+ font-family: "Courier New", Courier, monospace;
876
+ font-size: 0.85rem;
877
+ white-space: pre-wrap;
878
+ word-wrap: break-word;
879
+ border: 1px solid var(--border);
880
+ line-height: 1.4;
881
+ }}
882
+
883
+ /* API Key Overlay */
884
+ .auth-overlay {{
885
+ position: fixed;
886
+ top: 0;
887
+ left: 0;
888
+ width: 100%;
889
+ height: 100%;
890
+ background-color: var(--bg-main);
891
+ z-index: 1000;
892
+ display: flex;
893
+ flex-direction: column;
894
+ align-items: center;
895
+ justify-content: center;
896
+ gap: 20px;
897
+ padding: 20px;
898
+ }}
899
+
900
+ .auth-card {{
901
+ background-color: var(--bg-sidebar);
902
+ border: 1px solid var(--border);
903
+ border-radius: 12px;
904
+ width: 400px;
905
+ max-width: 100%;
906
+ padding: 30px;
907
+ box-shadow: var(--shadow);
908
+ display: flex;
909
+ flex-direction: column;
910
+ gap: 16px;
911
+ }}
912
+
913
+ .auth-title {{
914
+ font-size: 1.25rem;
915
+ font-weight: 700;
916
+ text-align: center;
917
+ }}
918
+
919
+ .auth-input {{
920
+ width: 100%;
921
+ padding: 10px 14px;
922
+ border: 1px solid var(--border);
923
+ border-radius: 8px;
924
+ background-color: var(--input-bg);
925
+ color: var(--text-primary);
926
+ font-size: 0.95rem;
927
+ outline: none;
928
+ transition: var(--transition);
929
+ }}
930
+
931
+ .auth-input:focus {{
932
+ border-color: var(--accent);
933
+ }}
934
+
935
+ .auth-button {{
936
+ background-color: var(--accent);
937
+ color: white;
938
+ border: none;
939
+ padding: 12px;
940
+ border-radius: 8px;
941
+ font-weight: 600;
942
+ cursor: pointer;
943
+ transition: var(--transition);
944
+ }}
945
+
946
+ .auth-button:hover {{
947
+ background-color: var(--accent-hover);
948
+ }}
949
+
950
+ .auth-error {{
951
+ color: #ef4444;
952
+ font-size: 0.85rem;
953
+ text-align: center;
954
+ display: none;
955
+ }}
956
+
957
+ /* Scrollbars */
958
+ ::-webkit-scrollbar {{
959
+ width: 6px;
960
+ height: 6px;
961
+ }}
962
+ ::-webkit-scrollbar-track {{
963
+ background: transparent;
964
+ }}
965
+ ::-webkit-scrollbar-thumb {{
966
+ background: rgba(120, 120, 120, 0.3);
967
+ border-radius: 4px;
968
+ }}
969
+ ::-webkit-scrollbar-thumb:hover {{
970
+ background: rgba(120, 120, 120, 0.5);
971
+ }}
972
+ </style>
973
+ </head>
974
+ <body class="dark-theme">
975
+
976
+ <!-- Auth Key Dialog Overlay -->
977
+ <div id="auth-overlay" class="auth-overlay" style="display: none;">
978
+ <div class="auth-card">
979
+ <h2 class="auth-title">🔒 Acceso Requerido</h2>
980
+ <p style="color: var(--text-secondary); font-size: 0.85rem; text-align: center; line-height: 1.4;">
981
+ Ingresa tu MASTER_API_KEY o ADMIN_API_KEY para autenticar la transmisión en tiempo real.
982
+ </p>
983
+ <input type="password" id="auth-input" class="auth-input" placeholder="Ingresa Admin Key...">
984
+ <button id="auth-submit" class="auth-button">Autenticar</button>
985
+ <div id="auth-error" class="auth-error">Clave de administrador incorrecta. Inténtalo de nuevo.</div>
986
+ </div>
987
+ </div>
988
+
989
+ <!-- Main Workspace -->
990
+ <div class="sidebar">
991
+ <div class="header">
992
+ <div class="brand-section">
993
+ <span class="live-indicator"></span>
994
+ <span class="brand-title">Conny Live Streams</span>
995
+ </div>
996
+ <button id="theme-btn" class="theme-toggle">🌓</button>
997
+ </div>
998
+
999
+ <div class="search-container">
1000
+ <div class="search-wrapper">
1001
+ <span class="search-icon">🔍</span>
1002
+ <input type="text" id="search-input" class="search-box" placeholder="Buscar conversación...">
1003
+ </div>
1004
+ </div>
1005
+
1006
+ <div id="chat-list" class="chat-list">
1007
+ <!-- Dynamic Active Chats Render -->
1008
+ <div style="padding: 20px; text-align: center; color: var(--text-secondary); font-size: 0.85rem;">
1009
+ Cargando conversaciones...
1010
+ </div>
1011
+ </div>
1012
+ </div>
1013
+
1014
+ <div class="main-chat">
1015
+ <!-- Selected Chat Header -->
1016
+ <div id="chat-header" class="header" style="display: none;">
1017
+ <div class="brand-section">
1018
+ <div class="avatar" id="header-avatar">C</div>
1019
+ <div>
1020
+ <div class="brand-title" id="header-chat-id">chat_id</div>
1021
+ <span id="header-msg-count" style="font-size: 0.75rem; color: var(--text-secondary)">0 mensajes</span>
1022
+ </div>
1023
+ </div>
1024
+ <div id="header-details" style="font-size: 0.8rem; color: var(--text-secondary)"></div>
1025
+ </div>
1026
+
1027
+ <!-- Chat messages screen -->
1028
+ <div id="chat-pane" class="chat-pane">
1029
+ <div class="splash-screen">
1030
+ <div class="splash-logo">🤖</div>
1031
+ <h2>Transmisión de Conversaciones</h2>
1032
+ <p style="max-width: 400px; line-height: 1.4; font-size: 0.9rem;">
1033
+ Selecciona una conversación del panel izquierdo para monitorear las respuestas de Conny en tiempo real, latencias y análisis cognitivo.
1034
+ </p>
1035
+ </div>
1036
+ </div>
1037
+ </div>
1038
+
1039
+ <!-- Modal for Cognitive Raw Analysis -->
1040
+ <div id="analysis-modal" class="modal-overlay">
1041
+ <div class="modal-content">
1042
+ <div class="modal-header">
1043
+ <h3 class="modal-title">🧠 Análisis Cognitivo de Conny</h3>
1044
+ <button id="modal-close-btn" class="modal-close">&times;</button>
1045
+ </div>
1046
+ <div class="modal-body">
1047
+ <p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 12px; line-height: 1.4;">
1048
+ Representa el razonamiento interno en tiempo real realizado por Conny (confianza, intenciones, triggers de anti-loops y resguardos).
1049
+ </p>
1050
+ <div id="json-viewer" class="json-viewer"></div>
1051
+ </div>
1052
+ </div>
1053
+ </div>
1054
+
1055
+ <script>
1056
+ const instanceId = "{instance_id}";
1057
+ let currentChatId = null;
1058
+ let adminKey = null;
1059
+ let chatsData = [];
1060
+ let pollingInterval = null;
1061
+ let lastMessageCount = 0;
1062
+
1063
+ // Auto theme detection/handling
1064
+ const themeBtn = document.getElementById("theme-btn");
1065
+ themeBtn.addEventListener("click", () => {{
1066
+ document.body.classList.toggle("light-theme");
1067
+ localStorage.setItem("conny_theme", document.body.classList.contains("light-theme") ? "light" : "dark");
1068
+ }});
1069
+
1070
+ if (localStorage.getItem("conny_theme") === "light") {{
1071
+ document.body.classList.add("light-theme");
1072
+ }}
1073
+
1074
+ // Resolve Authorization Key
1075
+ function initAuth() {{
1076
+ const urlParams = new URLSearchParams(window.location.search);
1077
+ const keyParam = urlParams.get("key");
1078
+ const storedKey = localStorage.getItem("conny_admin_key");
1079
+
1080
+ if (keyParam) {{
1081
+ adminKey = keyParam;
1082
+ localStorage.setItem("conny_admin_key", keyParam);
1083
+ startDashboard();
1084
+ }} else if (storedKey) {{
1085
+ adminKey = storedKey;
1086
+ startDashboard();
1087
+ }} else {{
1088
+ showAuthDialog();
1089
+ }}
1090
+ }}
1091
+
1092
+ function showAuthDialog() {{
1093
+ const overlay = document.getElementById("auth-overlay");
1094
+ overlay.style.display = "flex";
1095
+
1096
+ document.getElementById("auth-submit").addEventListener("click", submitAuth);
1097
+ document.getElementById("auth-input").addEventListener("keypress", (e) => {{
1098
+ if (e.key === "Enter") submitAuth();
1099
+ }});
1100
+ }}
1101
+
1102
+ async function submitAuth() {{
1103
+ const input = document.getElementById("auth-input").value.trim();
1104
+ if (!input) return;
1105
+
1106
+ // Validate admin key by fetching chats list
1107
+ try {{
1108
+ const res = await fetch(`/admin/${{instanceId}}/chats`, {{
1109
+ headers: {{ "X-Admin-Key": input }}
1110
+ }});
1111
+ if (res.ok) {{
1112
+ adminKey = input;
1113
+ localStorage.setItem("conny_admin_key", input);
1114
+ document.getElementById("auth-overlay").style.display = "none";
1115
+ startDashboard();
1116
+ }} else {{
1117
+ document.getElementById("auth-error").style.display = "block";
1118
+ }}
1119
+ }} catch (err) {{
1120
+ document.getElementById("auth-error").style.display = "block";
1121
+ }}
1122
+ }}
1123
+
1124
+ // Run dashboard features
1125
+ function startDashboard() {{
1126
+ fetchChats();
1127
+ pollingInterval = setInterval(() => {{
1128
+ fetchChats(true);
1129
+ if (currentChatId) {{
1130
+ fetchChatDetails(currentChatId, true);
1131
+ }}
1132
+ }}, 4000);
1133
+ }}
1134
+
1135
+ // Fetch active chats for sidebar
1136
+ async function fetchChats(isPoll = false) {{
1137
+ try {{
1138
+ const res = await fetch(`/admin/${{instanceId}}/chats`, {{
1139
+ headers: {{ "X-Admin-Key": adminKey }}
1140
+ }});
1141
+ if (res.status === 401) {{
1142
+ clearInterval(pollingInterval);
1143
+ localStorage.removeItem("conny_admin_key");
1144
+ location.reload();
1145
+ return;
1146
+ }}
1147
+ if (!res.ok) return;
1148
+ const data = await res.json();
1149
+ chatsData = data.chats || [];
1150
+ renderSidebar(isPoll);
1151
+ }} catch (err) {{
1152
+ console.error("Error fetching chats:", err);
1153
+ }}
1154
+ }}
1155
+
1156
+ // Render left sidebar chat list
1157
+ function renderSidebar(isPoll = false) {{
1158
+ const searchVal = document.getElementById("search-input").value.toLowerCase();
1159
+ const container = document.getElementById("chat-list");
1160
+
1161
+ const filtered = chatsData.filter(c => {{
1162
+ return c.chat_id.toLowerCase().includes(searchVal) ||
1163
+ (c.last_content || "").toLowerCase().includes(searchVal);
1164
+ }});
1165
+
1166
+ if (filtered.length === 0) {{
1167
+ container.innerHTML = `<div style="padding:20px; text-align:center; color:var(--text-secondary); font-size:0.85rem;">Ningún chat encontrado</div>`;
1168
+ return;
1169
+ }}
1170
+
1171
+ let html = "";
1172
+ filtered.forEach(chat => {{
1173
+ const isActive = chat.chat_id === currentChatId ? "active" : "";
1174
+ const dateStr = formatTimestamp(chat.ts);
1175
+ const roleIcon = chat.last_role === "user" ? "👤 " : "🤖 ";
1176
+ const initials = chat.chat_id.slice(-4);
1177
+
1178
+ html += `
1179
+ <div class="chat-item ${{isActive}}" onclick="selectChat('${{chat.chat_id}}')">
1180
+ <div class="avatar">${{initials}}</div>
1181
+ <div class="chat-info">
1182
+ <div class="chat-top">
1183
+ <span class="chat-name">${{chat.chat_id}}</span>
1184
+ <span class="chat-time">${{dateStr}}</span>
1185
+ </div>
1186
+ <div class="chat-bottom">
1187
+ <span class="chat-preview">${{roleIcon}}${{escapeHtml(chat.last_content || "")}}</span>
1188
+ <span class="badge">${{chat.msg_count}}</span>
1189
+ </div>
1190
+ </div>
1191
+ </div>
1192
+ `;
1193
+ }});
1194
+
1195
+ container.innerHTML = html;
1196
+ }}
1197
+
1198
+ // Search filter handling
1199
+ document.getElementById("search-input").addEventListener("input", () => renderSidebar(false));
1200
+
1201
+ // Format dates beautifully
1202
+ function formatTimestamp(tsStr) {{
1203
+ if (!tsStr) return "";
1204
+ try {{
1205
+ const d = new Date(tsStr.replace(" ", "T"));
1206
+ if (isNaN(d.getTime())) return tsStr;
1207
+ return d.toLocaleTimeString([], {{hour: '2-digit', minute:'2-digit'}});
1208
+ }} catch (e) {{
1209
+ return tsStr;
1210
+ }}
1211
+ }}
1212
+
1213
+ function escapeHtml(unsafe) {{
1214
+ return unsafe
1215
+ .replace(/&/g, "&amp;")
1216
+ .replace(/</g, "&lt;")
1217
+ .replace(/>/g, "&gt;")
1218
+ .replace(/"/g, "&quot;")
1219
+ .replace(/'/g, "&#039;");
1220
+ }}
1221
+
1222
+ // Selection of active conversation
1223
+ function selectChat(chatId) {{
1224
+ currentChatId = chatId;
1225
+ lastMessageCount = 0;
1226
+
1227
+ // Highlight active sidebar item
1228
+ const items = document.querySelectorAll(".chat-item");
1229
+ items.forEach(el => {{
1230
+ const name = el.querySelector(".chat-name").innerText;
1231
+ if (name === chatId) el.classList.add("active");
1232
+ else el.classList.remove("active");
1233
+ }});
1234
+
1235
+ // Prepare header
1236
+ document.getElementById("chat-header").style.display = "flex";
1237
+ document.getElementById("header-chat-id").innerText = chatId;
1238
+ document.getElementById("header-avatar").innerText = chatId.slice(-4);
1239
+
1240
+ const pane = document.getElementById("chat-pane");
1241
+ pane.innerHTML = `<div style="display:flex; height:100%; align-items:center; justify-content:center; color:var(--text-secondary);">Cargando mensajes de ${{chatId}}...</div>`;
1242
+
1243
+ fetchChatDetails(chatId, false);
1244
+ }}
1245
+
1246
+ // Fetch details from sqlite for selected chat_id
1247
+ async function fetchChatDetails(chatId, isPoll = false) {{
1248
+ if (chatId !== currentChatId) return;
1249
+ try {{
1250
+ const res = await fetch(`/admin/${{instanceId}}/chats/${{chatId}}`, {{
1251
+ headers: {{ "X-Admin-Key": adminKey }}
1252
+ }});
1253
+ if (!res.ok) return;
1254
+ const data = await res.json();
1255
+ renderMessages(data.messages || [], isPoll);
1256
+ }} catch (err) {{
1257
+ console.error("Error fetching chat details:", err);
1258
+ }}
1259
+ }}
1260
+
1261
+ // Render bubbles in thread pane
1262
+ function renderMessages(messages, isPoll = false) {{
1263
+ const pane = document.getElementById("chat-pane");
1264
+
1265
+ if (messages.length === 0) {{
1266
+ pane.innerHTML = `<div style="display:flex; height:100%; align-items:center; justify-content:center; color:var(--text-secondary);">Ningún mensaje en esta conversación</div>`;
1267
+ return;
1268
+ }}
1269
+
1270
+ // Only update DOM if message count or last message content has changed
1271
+ if (isPoll && messages.length === lastMessageCount) {{
1272
+ return;
1273
+ }}
1274
+
1275
+ const wasAtBottom = pane.scrollHeight - pane.scrollTop <= pane.clientHeight + 100;
1276
+
1277
+ let html = "";
1278
+ let connyTurns = [];
1279
+
1280
+ messages.forEach((msg, idx) => {{
1281
+ const timeStr = formatTimestamp(msg.ts);
1282
+
1283
+ if (msg.role === "user") {{
1284
+ // Render user bubbles directly
1285
+ html += `
1286
+ <div class="message-row user-row">
1287
+ <div class="bubble">
1288
+ <div class="bubble-text">${{escapeHtml(msg.content)}}</div>
1289
+ <div class="bubble-meta">
1290
+ <span>${{timeStr}}</span>
1291
+ <span>✓✓</span>
1292
+ </div>
1293
+ </div>
1294
+ </div>
1295
+ `;
1296
+ }} else {{
1297
+ // Split Conny bubbles using " ||| " separator
1298
+ const bubbles = msg.content.split(" ||| ");
1299
+ bubbles.forEach((bubbleText, bIdx) => {{
1300
+ html += `
1301
+ <div class="message-row conny-row">
1302
+ <div class="bubble">
1303
+ <div class="bubble-text">${{escapeHtml(bubbleText)}}</div>
1304
+ <div class="bubble-meta">
1305
+ <span>${{timeStr}}</span>
1306
+ </div>
1307
+ </div>
1308
+ </div>
1309
+ `;
1310
+ }});
1311
+
1312
+ // Render cognitive insights pills directly under final bubble of the assistant turn
1313
+ const latencySec = msg.latency_ms ? (msg.latency_ms / 1000).toFixed(1) + "s" : "N/A";
1314
+ const modelName = msg.model_used || "conny-brain";
1315
+ const tokens = msg.tokens_used ? msg.tokens_used + " tokens" : "N/A";
1316
+ const analysisStr = JSON.stringify(msg.analysis);
1317
+
1318
+ html += `
1319
+ <div class="insights-container">
1320
+ <div class="insights-row">
1321
+ <div class="pill">🤖 ${{modelName}}</div>
1322
+ <div class="pill">⚡ ${{latencySec}}</div>
1323
+ <div class="pill">🪙 ${{tokens}}</div>
1324
+ <div class="pill pill-interactive" onclick='showAnalysis(${{escapeHtmlAttribute(analysisStr)}})'>🧠 Razonamiento</div>
1325
+ </div>
1326
+ </div>
1327
+ `;
1328
+ }}
1329
+ }});
1330
+
1331
+ pane.innerHTML = html;
1332
+ document.getElementById("header-msg-count").innerText = `${{messages.length}} mensajes`;
1333
+
1334
+ // Auto scroll down if user was already at the bottom or if it's the first click
1335
+ if (!isPoll || wasAtBottom || lastMessageCount === 0) {{
1336
+ pane.scrollTop = pane.scrollHeight;
1337
+ }}
1338
+
1339
+ lastMessageCount = messages.length;
1340
+ }}
1341
+
1342
+ function escapeHtmlAttribute(str) {{
1343
+ return str
1344
+ .replace(/'/g, "&apos;")
1345
+ .replace(/"/g, "&quot;");
1346
+ }}
1347
+
1348
+ // Cognitive modal functions
1349
+ function showAnalysis(analysisObj) {{
1350
+ const modal = document.getElementById("analysis-modal");
1351
+ const viewer = document.getElementById("json-viewer");
1352
+
1353
+ let prettyJson = "";
1354
+ try {{
1355
+ prettyJson = JSON.stringify(analysisObj, null, 2);
1356
+ }} catch (e) {{
1357
+ prettyJson = String(analysisObj);
1358
+ }}
1359
+
1360
+ viewer.innerText = prettyJson;
1361
+ modal.classList.add("active");
1362
+ }}
1363
+
1364
+ // Modal triggers
1365
+ document.getElementById("modal-close-btn").addEventListener("click", hideModal);
1366
+ document.getElementById("analysis-modal").addEventListener("click", (e) => {{
1367
+ if (e.target.id === "analysis-modal") hideModal();
1368
+ }});
1369
+
1370
+ function hideModal() {{
1371
+ document.getElementById("analysis-modal").classList.remove("active");
1372
+ }}
1373
+
1374
+ // Fire auth on page load
1375
+ window.addEventListener("DOMContentLoaded", initAuth);
1376
+ </script>
1377
+ </body>
1378
+ </html>"""
1379
+ return HTMLResponse(content=html_content, status_code=200)