@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.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- 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">×</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, "&")
|
|
1216
|
+
.replace(/</g, "<")
|
|
1217
|
+
.replace(/>/g, ">")
|
|
1218
|
+
.replace(/"/g, """)
|
|
1219
|
+
.replace(/'/g, "'");
|
|
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, "'")
|
|
1345
|
+
.replace(/"/g, """);
|
|
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)
|