dulus 0.2.0__py3-none-any.whl
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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
webchat_server.py
ADDED
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
"""Dulus WebChat — in-process mirror of the terminal agent + Roundtable mode.
|
|
2
|
+
"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
import webbrowser
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Generator
|
|
14
|
+
|
|
15
|
+
from backend.context import build_context, build_smart_context, get_compact_context
|
|
16
|
+
from backend.personas import create_persona, get_active_persona, get_all_personas, get_persona, load_personas, set_active_persona, update_persona
|
|
17
|
+
from backend.plugins import load_all_plugins, get_plugin_info, start_watcher, stop_watcher, watcher_status, reload_plugin, unload_plugin
|
|
18
|
+
from task import create_task as task_create, list_tasks as task_list, update_task as task_update, get_task as task_get, delete_task as task_delete
|
|
19
|
+
from backend.marketplace import load_registry, search_plugins, get_stats as marketplace_stats, install_plugin, uninstall_plugin
|
|
20
|
+
|
|
21
|
+
DASHBOARD_DIR = Path(__file__).parent / "docs" / "dashboard"
|
|
22
|
+
|
|
23
|
+
from flask import Flask, request, jsonify, Response, stream_with_context
|
|
24
|
+
|
|
25
|
+
from agent import (
|
|
26
|
+
run as agent_run,
|
|
27
|
+
AgentState,
|
|
28
|
+
TextChunk,
|
|
29
|
+
ThinkingChunk,
|
|
30
|
+
ToolStart,
|
|
31
|
+
ToolEnd,
|
|
32
|
+
TurnDone,
|
|
33
|
+
PermissionRequest,
|
|
34
|
+
)
|
|
35
|
+
from context import build_system_prompt
|
|
36
|
+
from common import sanitize_text
|
|
37
|
+
|
|
38
|
+
# Ensure tools are registered
|
|
39
|
+
import tools as _tools_init
|
|
40
|
+
import memory.tools as _mem_tools_init
|
|
41
|
+
import multi_agent.tools as _ma_tools_init
|
|
42
|
+
import skill.tools as _sk_tools_init
|
|
43
|
+
import dulus_mcp.tools as _mcp_tools_init
|
|
44
|
+
import task.tools as _task_tools_init
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
import tmux_tools as _tmux_tools_init
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# ─────────── SSE Broadcast System ───────────
|
|
52
|
+
_sse_clients: list[queue.Queue] = []
|
|
53
|
+
_sse_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
def _add_sse_client(q: queue.Queue):
|
|
56
|
+
with _sse_lock:
|
|
57
|
+
_sse_clients.append(q)
|
|
58
|
+
|
|
59
|
+
def _remove_sse_client(q: queue.Queue):
|
|
60
|
+
with _sse_lock:
|
|
61
|
+
if q in _sse_clients:
|
|
62
|
+
_sse_clients.remove(q)
|
|
63
|
+
|
|
64
|
+
def broadcast_event(event_type: str, payload: dict):
|
|
65
|
+
"""Broadcast JSON event to all connected SSE clients."""
|
|
66
|
+
data = json.dumps({"type": event_type, "data": payload, "ts": time.time()})
|
|
67
|
+
msg = f"event: {event_type}\ndata: {data}\n\n"
|
|
68
|
+
with _sse_lock:
|
|
69
|
+
dead = []
|
|
70
|
+
for q in _sse_clients:
|
|
71
|
+
try:
|
|
72
|
+
q.put_nowait(msg)
|
|
73
|
+
except queue.Full:
|
|
74
|
+
dead.append(q)
|
|
75
|
+
for q in dead:
|
|
76
|
+
_sse_clients.remove(q)
|
|
77
|
+
|
|
78
|
+
def _sse_heartbeat():
|
|
79
|
+
"""Send periodic ping to keep connections alive."""
|
|
80
|
+
while True:
|
|
81
|
+
time.sleep(15)
|
|
82
|
+
broadcast_event("ping", {"status": "ok"})
|
|
83
|
+
|
|
84
|
+
threading.Thread(target=_sse_heartbeat, daemon=True, name="sse-heartbeat").start()
|
|
85
|
+
|
|
86
|
+
# ── shared refs ────────────────────────────────────────────────────────────
|
|
87
|
+
STATE: AgentState | None = None
|
|
88
|
+
CONFIG: dict | None = None
|
|
89
|
+
_LOCK = threading.Lock()
|
|
90
|
+
_PENDING_PERMISSIONS: dict[str, tuple[PermissionRequest, threading.Event]] = {}
|
|
91
|
+
|
|
92
|
+
_SERVER_THREAD: threading.Thread | None = None
|
|
93
|
+
_SERVER_PORT: int = 5000
|
|
94
|
+
_WERKZEUG_SERVER = None
|
|
95
|
+
|
|
96
|
+
# ── roundtable state ───────────────────────────────────────────────────────
|
|
97
|
+
class RoundtableAgent:
|
|
98
|
+
def __init__(self, agent_id: str, model: str):
|
|
99
|
+
self.id = agent_id
|
|
100
|
+
self.model = model
|
|
101
|
+
self.state = AgentState()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
ROUNDTABLE_AGENTS: list[RoundtableAgent] = []
|
|
105
|
+
ROUNDTABLE_HISTORY: list[tuple[str, str]] = [] # (author_id, text) global log
|
|
106
|
+
ROUNDTABLE_LOCK = threading.Lock()
|
|
107
|
+
|
|
108
|
+
# Per-agent cancellation tokens for roundtable
|
|
109
|
+
_AGENT_STOP_EVENTS: dict[str, threading.Event] = {}
|
|
110
|
+
_STOP_EVENTS_LOCK = threading.Lock()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _ensure_plugin_tools() -> None:
|
|
114
|
+
try:
|
|
115
|
+
from plugin.loader import register_plugin_tools
|
|
116
|
+
register_plugin_tools()
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_ANSI_RE = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _strip_ansi(text: str) -> str:
|
|
125
|
+
global _ANSI_RE
|
|
126
|
+
if _ANSI_RE is None:
|
|
127
|
+
import re
|
|
128
|
+
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
|
|
129
|
+
return _ANSI_RE.sub('', text)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run_slash_command(cmd_line: str) -> tuple[str, str | None]:
|
|
133
|
+
"""Run a slash command through the REPL's registered handler,
|
|
134
|
+
capturing stdout. Mirrors the Telegram bridge behavior
|
|
135
|
+
(dulus.py:_handle_slash_from_telegram).
|
|
136
|
+
|
|
137
|
+
Returns (output_text, assistant_reply_or_None).
|
|
138
|
+
`assistant_reply` is set when the slash triggered a model query
|
|
139
|
+
(cmd_type == "query") so the caller can stream it as a separate chunk.
|
|
140
|
+
"""
|
|
141
|
+
import io
|
|
142
|
+
if CONFIG is None:
|
|
143
|
+
return ("[webchat] server not initialized", None)
|
|
144
|
+
slash_cb = CONFIG.get("_handle_slash_callback")
|
|
145
|
+
if not slash_cb:
|
|
146
|
+
return (
|
|
147
|
+
f"[webchat] slash commands unavailable — REPL not active.\n"
|
|
148
|
+
f"Command was: {cmd_line}",
|
|
149
|
+
None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
old_stdout = sys.stdout
|
|
153
|
+
buf = io.StringIO()
|
|
154
|
+
sys.stdout = buf
|
|
155
|
+
try:
|
|
156
|
+
try:
|
|
157
|
+
cmd_type = slash_cb(cmd_line)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
return (f"⚠ Error: {type(e).__name__}: {e}", None)
|
|
160
|
+
finally:
|
|
161
|
+
sys.stdout = old_stdout
|
|
162
|
+
|
|
163
|
+
captured = _strip_ansi(buf.getvalue()).strip()
|
|
164
|
+
if not captured and cmd_type == "simple":
|
|
165
|
+
cmd_name = cmd_line.strip().split()[0]
|
|
166
|
+
captured = f"✅ {cmd_name} executed."
|
|
167
|
+
|
|
168
|
+
assistant_reply: str | None = None
|
|
169
|
+
if cmd_type == "query" and STATE is not None and STATE.messages:
|
|
170
|
+
for m in reversed(STATE.messages):
|
|
171
|
+
if m.get("role") == "assistant":
|
|
172
|
+
content = m.get("content", "")
|
|
173
|
+
if isinstance(content, list):
|
|
174
|
+
parts = []
|
|
175
|
+
for block in content:
|
|
176
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
177
|
+
parts.append(block["text"])
|
|
178
|
+
elif isinstance(block, str):
|
|
179
|
+
parts.append(block)
|
|
180
|
+
content = "\n".join(parts)
|
|
181
|
+
if content:
|
|
182
|
+
assistant_reply = content
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
return (captured, assistant_reply)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _run_agent_mirror(user_message: str) -> Generator:
|
|
189
|
+
"""Run the agent loop with shared state/config, yielding all events."""
|
|
190
|
+
_ensure_plugin_tools()
|
|
191
|
+
if STATE is None or CONFIG is None:
|
|
192
|
+
raise RuntimeError("webchat server not initialized")
|
|
193
|
+
|
|
194
|
+
cfg = CONFIG
|
|
195
|
+
state = STATE
|
|
196
|
+
user_input = sanitize_text(user_message)
|
|
197
|
+
|
|
198
|
+
_skill_body = cfg.pop("_skill_inject", "")
|
|
199
|
+
if _skill_body:
|
|
200
|
+
user_input = (
|
|
201
|
+
"[SKILL CONTEXT — follow these instructions for this turn]\n\n"
|
|
202
|
+
+ _skill_body
|
|
203
|
+
+ "\n\n---\n\n[USER MESSAGE]\n"
|
|
204
|
+
+ user_input
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if cfg.get("mem_palace", True) and user_input and len(user_input.strip()) >= 12:
|
|
208
|
+
_trivial = {
|
|
209
|
+
"hola", "klk", "gracias", "ok", "si", "no", "dale",
|
|
210
|
+
"exit", "quit", "help", "thanks", "bien",
|
|
211
|
+
}
|
|
212
|
+
_first = user_input.strip().lower().split()[0]
|
|
213
|
+
if _first not in _trivial:
|
|
214
|
+
try:
|
|
215
|
+
from memory import find_relevant_memories
|
|
216
|
+
_q = user_input.strip()[:200]
|
|
217
|
+
_raw_hits = find_relevant_memories(_q, max_results=3)
|
|
218
|
+
_raw_hits = [h for h in _raw_hits if h.get("keyword_score", 0.0) >= 60.0]
|
|
219
|
+
if _raw_hits:
|
|
220
|
+
_parts = []
|
|
221
|
+
for _i, _h in enumerate(_raw_hits, 1):
|
|
222
|
+
_name = _h.get("name", f"hit_{_i}")
|
|
223
|
+
_desc = _h.get("description", "")
|
|
224
|
+
_body = _h.get("content", "").strip()
|
|
225
|
+
_snip = _body[:300] + ("..." if len(_body) > 300 else "")
|
|
226
|
+
if _desc:
|
|
227
|
+
_parts.append(f"### {_name}\n_{_desc}_\n{_snip}")
|
|
228
|
+
else:
|
|
229
|
+
_parts.append(f"### {_name}\n{_snip}")
|
|
230
|
+
_hits_str = "\n\n".join(_parts)
|
|
231
|
+
if len(_hits_str) > 2000:
|
|
232
|
+
_hits_str = _hits_str[:2000] + "\n[...truncated]"
|
|
233
|
+
_inject = (
|
|
234
|
+
"[MemPalace — relevant memories pre-loaded for this turn. "
|
|
235
|
+
"Do NOT re-query unless the user explicitly asks for more.]\n\n"
|
|
236
|
+
+ _hits_str
|
|
237
|
+
)
|
|
238
|
+
user_input = (
|
|
239
|
+
_inject + "\n\n---\n\n[USER MESSAGE]\n" + user_input
|
|
240
|
+
)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
system_prompt = build_system_prompt(cfg)
|
|
245
|
+
cfg.pop("_in_telegram_turn", None)
|
|
246
|
+
cfg["_last_interaction_time"] = time.time()
|
|
247
|
+
|
|
248
|
+
yield from agent_run(user_input, state, cfg, system_prompt)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
import checkpoint as ckpt
|
|
252
|
+
session_id = cfg.get("_session_id", "default")
|
|
253
|
+
tracked = ckpt.get_tracked_edits()
|
|
254
|
+
last_snaps = ckpt.list_snapshots(session_id)
|
|
255
|
+
skip = False
|
|
256
|
+
if not tracked and last_snaps:
|
|
257
|
+
if len(state.messages) == last_snaps[-1].get("message_index", -1):
|
|
258
|
+
skip = True
|
|
259
|
+
if not skip:
|
|
260
|
+
ckpt.make_snapshot(session_id, state, cfg, user_input, tracked_edits=tracked)
|
|
261
|
+
ckpt.reset_tracked()
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _event_to_dict(event) -> dict | None:
|
|
267
|
+
if isinstance(event, TextChunk):
|
|
268
|
+
return {"type": "text", "text": event.text}
|
|
269
|
+
elif isinstance(event, ThinkingChunk):
|
|
270
|
+
return {"type": "thinking", "text": event.text}
|
|
271
|
+
elif isinstance(event, ToolStart):
|
|
272
|
+
return {"type": "tool_start", "name": event.name, "inputs": event.inputs}
|
|
273
|
+
elif isinstance(event, ToolEnd):
|
|
274
|
+
return {"type": "tool_end", "name": event.name, "result": event.result, "permitted": event.permitted}
|
|
275
|
+
elif isinstance(event, TurnDone):
|
|
276
|
+
return {
|
|
277
|
+
"type": "turn_done",
|
|
278
|
+
"in": event.input_tokens,
|
|
279
|
+
"out": event.output_tokens,
|
|
280
|
+
"cache_read": getattr(event, "cache_read_tokens", 0),
|
|
281
|
+
"cache_write": getattr(event, "cache_creation_tokens", 0),
|
|
282
|
+
}
|
|
283
|
+
elif isinstance(event, PermissionRequest):
|
|
284
|
+
pid = str(uuid.uuid4())
|
|
285
|
+
evt = threading.Event()
|
|
286
|
+
_PENDING_PERMISSIONS[pid] = (event, evt)
|
|
287
|
+
payload = {"type": "permission", "id": pid, "description": event.description}
|
|
288
|
+
return payload, evt
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _sanitize_for_api(text: str) -> str:
|
|
293
|
+
"""Aggressive sanitize: remove control chars (except \n\r\t), surrogates, and normalize."""
|
|
294
|
+
if not isinstance(text, str):
|
|
295
|
+
text = str(text)
|
|
296
|
+
# Step 1: remove UTF-16 surrogates
|
|
297
|
+
text = "".join(c for c in text if not (0xD800 <= ord(c) <= 0xDFFF))
|
|
298
|
+
# Step 2: remove control characters except newline, carriage return, tab
|
|
299
|
+
text = "".join(c for c in text if ord(c) >= 32 or c in "\n\r\t")
|
|
300
|
+
# Step 3: normalize fancy quotes to plain quotes
|
|
301
|
+
text = text.replace("\u201c", '"').replace("\u201d", '"')
|
|
302
|
+
text = text.replace("\u2018", "'").replace("\u2019", "'")
|
|
303
|
+
text = text.replace("\u2013", "-").replace("\u2014", "-")
|
|
304
|
+
# Step 4: strip leading/trailing whitespace per line but keep structure
|
|
305
|
+
return text.strip()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _build_roundtable_prompt(agent: RoundtableAgent, user_msg: str, history: list[tuple[str, str]]) -> str:
|
|
309
|
+
user_msg = _sanitize_for_api(user_msg)
|
|
310
|
+
ctx_parts = []
|
|
311
|
+
for author, text in history:
|
|
312
|
+
text = _sanitize_for_api(text)
|
|
313
|
+
if text:
|
|
314
|
+
ctx_parts.append(f"[{author}]: {text}")
|
|
315
|
+
if ctx_parts:
|
|
316
|
+
ctx = "\n".join(ctx_parts)
|
|
317
|
+
return (
|
|
318
|
+
f"[Mesa Redonda - Contexto Compartido]\n\n"
|
|
319
|
+
f"Historial:\n{ctx}\n\n"
|
|
320
|
+
f"Usuario ahora: {user_msg}\n\n"
|
|
321
|
+
f"Instrucción individual: Eres el miembro {agent.id}. Responde desde tu perspectiva."
|
|
322
|
+
)
|
|
323
|
+
return (
|
|
324
|
+
f"[Mesa Redonda - Contexto Compartido]\n\n"
|
|
325
|
+
f"Estás en una mesa redonda junto con otros agentes. "
|
|
326
|
+
f"Cada uno de ustedes aportará su perspectiva sobre el tema. "
|
|
327
|
+
f"Sé conciso pero completo en tu respuesta.\n\n"
|
|
328
|
+
f"Usuario ahora: {user_msg}\n\n"
|
|
329
|
+
f"Instrucción individual: Eres el miembro {agent.id}. Responde desde tu perspectiva."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _run_agent_for_roundtable(agent: RoundtableAgent, user_msg: str, history: list[tuple[str, str]], q: queue.Queue):
|
|
334
|
+
stop_evt = threading.Event()
|
|
335
|
+
with _STOP_EVENTS_LOCK:
|
|
336
|
+
_AGENT_STOP_EVENTS[agent.id] = stop_evt
|
|
337
|
+
try:
|
|
338
|
+
_ensure_plugin_tools()
|
|
339
|
+
if CONFIG is None:
|
|
340
|
+
q.put({"agent": agent.id, "type": "error", "message": "server not initialized"})
|
|
341
|
+
return
|
|
342
|
+
cfg = dict(CONFIG)
|
|
343
|
+
cfg["model"] = agent.model
|
|
344
|
+
prompt = _sanitize_for_api(_build_roundtable_prompt(agent, user_msg, history))
|
|
345
|
+
system_prompt = build_system_prompt(cfg)
|
|
346
|
+
cfg.pop("_in_telegram_turn", None)
|
|
347
|
+
cfg["_last_interaction_time"] = time.time()
|
|
348
|
+
|
|
349
|
+
# Optimize tokens: clear state to prevent N^2 duplication of history
|
|
350
|
+
# and to dump bulky transient tool outputs (e.g. bash stdout).
|
|
351
|
+
agent.state.messages.clear()
|
|
352
|
+
|
|
353
|
+
stopped = False
|
|
354
|
+
for event in agent_run(prompt, agent.state, cfg, system_prompt):
|
|
355
|
+
if stop_evt.is_set():
|
|
356
|
+
stopped = True
|
|
357
|
+
q.put({"agent": agent.id, "type": "agent_stopped"})
|
|
358
|
+
break
|
|
359
|
+
result = _event_to_dict(event)
|
|
360
|
+
if result is None:
|
|
361
|
+
continue
|
|
362
|
+
if isinstance(result, tuple):
|
|
363
|
+
payload, evt = result
|
|
364
|
+
payload["agent"] = agent.id
|
|
365
|
+
q.put(payload)
|
|
366
|
+
evt.wait(timeout=300)
|
|
367
|
+
_PENDING_PERMISSIONS.pop(payload["id"], None)
|
|
368
|
+
continue
|
|
369
|
+
payload = result
|
|
370
|
+
payload["agent"] = agent.id
|
|
371
|
+
q.put(payload)
|
|
372
|
+
|
|
373
|
+
if not stopped:
|
|
374
|
+
final_text = ""
|
|
375
|
+
if agent.state.messages:
|
|
376
|
+
for msg in reversed(agent.state.messages):
|
|
377
|
+
if msg.get("role") == "assistant" and msg.get("content"):
|
|
378
|
+
final_text = msg["content"]
|
|
379
|
+
break
|
|
380
|
+
q.put({"agent": agent.id, "type": "agent_done", "text": final_text})
|
|
381
|
+
except Exception as exc:
|
|
382
|
+
q.put({"agent": agent.id, "type": "error", "message": f"{type(exc).__name__}: {exc}"})
|
|
383
|
+
finally:
|
|
384
|
+
with _STOP_EVENTS_LOCK:
|
|
385
|
+
_AGENT_STOP_EVENTS.pop(agent.id, None)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ── Flask app ──────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
def create_app() -> Flask:
|
|
391
|
+
app = Flask(__name__)
|
|
392
|
+
import logging as _logging
|
|
393
|
+
_logging.getLogger("werkzeug").setLevel(_logging.ERROR)
|
|
394
|
+
app.logger.disabled = True
|
|
395
|
+
|
|
396
|
+
# ───────────────────────── Chat Normal HTML ─────────────────────────
|
|
397
|
+
CHAT_PAGE = r"""<!doctype html>
|
|
398
|
+
<html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
399
|
+
<title>Dulus WebChat</title>
|
|
400
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
401
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
402
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Archivo+Black&display=swap" rel="stylesheet">
|
|
403
|
+
<style>
|
|
404
|
+
:root{
|
|
405
|
+
--bg:#0a0a0a;
|
|
406
|
+
--bg2:#0f0f12;
|
|
407
|
+
--bg3:#15151a;
|
|
408
|
+
--ink:#f0e8df;
|
|
409
|
+
--dim:#6a6470;
|
|
410
|
+
--dim2:#3a3840;
|
|
411
|
+
--accent:#ff6b1f;
|
|
412
|
+
--accent2:#ffb347;
|
|
413
|
+
--mono:'JetBrains Mono',monospace;
|
|
414
|
+
--display:'Archivo Black','Impact',sans-serif;
|
|
415
|
+
--radius:4px;
|
|
416
|
+
}
|
|
417
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
418
|
+
html{scroll-behavior:smooth;font-size:16px}
|
|
419
|
+
body{background:var(--bg);color:var(--ink);font-family:var(--mono);height:100vh;display:flex;flex-direction:column;position:relative}
|
|
420
|
+
::-webkit-scrollbar{width:6px}
|
|
421
|
+
::-webkit-scrollbar-track{background:var(--bg)}
|
|
422
|
+
::-webkit-scrollbar-thumb{background:var(--accent);border-radius:3px}
|
|
423
|
+
|
|
424
|
+
.grid-bg{
|
|
425
|
+
position:fixed;inset:0;pointer-events:none;z-index:0;
|
|
426
|
+
background-image:linear-gradient(rgba(255,107,31,.06) 1px,transparent 1px),
|
|
427
|
+
linear-gradient(90deg,rgba(255,107,31,.06) 1px,transparent 1px);
|
|
428
|
+
background-size:40px 40px;
|
|
429
|
+
mask-image:radial-gradient(ellipse at center,black 30%,transparent 80%);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
header{padding:0 40px;height:64px;background:rgba(10,10,10,.7);backdrop-filter:blur(16px);border-bottom:1px solid rgba(255,107,31,.12);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;position:relative;z-index:100}
|
|
433
|
+
header h1{font-family:var(--display);font-size:18px;letter-spacing:-.02em;color:var(--ink);display:flex;align-items:center;gap:12px}
|
|
434
|
+
header h1::before{content:"▲";font-size:18px;color:#000;background:var(--accent);width:32px;height:32px;display:grid;place-items:center;clip-path:polygon(50% 0%,100% 25%,100% 75%,50% 100%,0% 75%,0% 25%);}
|
|
435
|
+
|
|
436
|
+
header .model{font-size:11px;color:var(--dim)}
|
|
437
|
+
header a,header button{background:var(--bg2);color:var(--dim);border:1px solid var(--dim2);padding:6px 12px;border-radius:var(--radius);cursor:pointer;font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;text-decoration:none;transition:background .2s,border-color .2s,color .2s}
|
|
438
|
+
header a:hover,header button:hover{background:rgba(255,107,31,.1);border-color:var(--accent);color:var(--accent)}
|
|
439
|
+
#log{flex:1;overflow-y:auto;padding:24px 40px;display:flex;flex-direction:column;gap:16px;position:relative;z-index:1}
|
|
440
|
+
.msg{max-width:780px;padding:12px 16px;border-radius:6px;white-space:pre-wrap;word-wrap:break-word;font-size:14px}
|
|
441
|
+
.user{align-self:flex-end;background:rgba(255,107,31,.1);border:1px solid rgba(255,107,31,.25)}
|
|
442
|
+
.assistant{align-self:flex-start;background:var(--bg3);border:1px solid var(--dim2)}
|
|
443
|
+
.meta{font-size:10px;color:var(--dim);margin-top:6px}
|
|
444
|
+
.err{color:#ff5a6e;border-color:rgba(255,90,110,.4) !important}
|
|
445
|
+
#inputArea{display:flex;gap:10px;padding:16px 40px;background:var(--bg2);border-top:1px solid var(--dim2);position:relative;z-index:100}
|
|
446
|
+
textarea{flex:1;background:var(--bg3);color:var(--ink);border:1px solid var(--dim2);padding:12px;border-radius:var(--radius);font-family:var(--mono);font-size:14px;resize:none;height:64px;outline:none;transition:border-color .2s}
|
|
447
|
+
textarea:focus{border-color:var(--accent)}
|
|
448
|
+
textarea::placeholder{color:var(--dim)}
|
|
449
|
+
button.send{background:var(--accent);color:#000;border:none;padding:0 24px;border-radius:var(--radius);font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .2s}
|
|
450
|
+
button.send:hover{background:var(--accent2)}
|
|
451
|
+
button.send:disabled{opacity:.4;cursor:not-allowed}
|
|
452
|
+
.think{font-size:11px;color:var(--dim);margin-top:8px;padding:8px 12px;border-left:2px solid var(--dim2);background:rgba(0,0,0,.2);white-space:pre-wrap}
|
|
453
|
+
.tool{font-size:11px;color:#a39ca8;margin-top:8px;padding:8px 12px;border-left:2px solid var(--accent);background:rgba(255,107,31,.04);white-space:pre-wrap}
|
|
454
|
+
.tool-result{font-size:11px;color:var(--dim);margin-top:4px;padding:8px 12px;border-left:2px solid var(--dim2);background:rgba(0,0,0,.2);white-space:pre-wrap;max-height:200px;overflow-y:auto}
|
|
455
|
+
.perm{font-size:12px;color:#ffd166;margin-top:8px;padding:12px;border:1px solid rgba(255,209,102,.25);background:rgba(255,209,102,.1);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
456
|
+
.perm button{background:var(--bg3);color:var(--ink);border:1px solid var(--dim2);padding:6px 14px;border-radius:3px;cursor:pointer;font-weight:700}
|
|
457
|
+
.perm button.approve{background:var(--accent);color:#000;border:none}
|
|
458
|
+
@media(max-width:600px){
|
|
459
|
+
header{padding:0 20px;height:auto;padding-bottom:10px}
|
|
460
|
+
#log{padding:16px 20px}
|
|
461
|
+
.msg{max-width:92%}
|
|
462
|
+
#inputArea{padding:16px 20px}
|
|
463
|
+
}
|
|
464
|
+
</style></head><body>
|
|
465
|
+
<div class="grid-bg"></div>
|
|
466
|
+
<header>
|
|
467
|
+
<h1>DULUS WEBCHAT</h1>
|
|
468
|
+
<select id="personaSelect" style="background:var(--bg3);color:var(--dim);border:1px solid var(--dim2);padding:4px 10px;border-radius:var(--radius);font-family:var(--mono);font-size:12px;outline:none;cursor:pointer;flex:1;max-width:250px;margin:0 15px;text-align:center"></select>
|
|
469
|
+
<div>
|
|
470
|
+
<a href="/roundtable">Mesa Redonda</a>
|
|
471
|
+
<a href="/dashboard">Task Manager</a>
|
|
472
|
+
<button onclick="clearChat()">clear</button>
|
|
473
|
+
</div>
|
|
474
|
+
</header>
|
|
475
|
+
<div id="log"></div>
|
|
476
|
+
<div id="inputArea">
|
|
477
|
+
<textarea id="inp" placeholder="Mensaje a Dulus... (Enter envia, Shift+Enter nueva linea)" autofocus></textarea>
|
|
478
|
+
<button class="send" id="sendBtn">SEND</button>
|
|
479
|
+
</div>
|
|
480
|
+
<script>
|
|
481
|
+
const log=document.getElementById('log');
|
|
482
|
+
const inp=document.getElementById('inp');
|
|
483
|
+
const btn=document.getElementById('sendBtn');
|
|
484
|
+
|
|
485
|
+
function add(role,text,extra){
|
|
486
|
+
const d=document.createElement('div');
|
|
487
|
+
d.className='msg '+role;
|
|
488
|
+
d.textContent=text;
|
|
489
|
+
if(extra){
|
|
490
|
+
const m=document.createElement('div');
|
|
491
|
+
m.className='meta';
|
|
492
|
+
m.textContent=extra;
|
|
493
|
+
d.appendChild(m);
|
|
494
|
+
}
|
|
495
|
+
log.appendChild(d);
|
|
496
|
+
log.scrollTop=log.scrollHeight;
|
|
497
|
+
return d;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
let currentAssistant=null;
|
|
501
|
+
let currentText='';
|
|
502
|
+
|
|
503
|
+
function ensureAssistant(){
|
|
504
|
+
if(!currentAssistant){
|
|
505
|
+
currentAssistant=add('assistant','');
|
|
506
|
+
}
|
|
507
|
+
return currentAssistant;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function appendText(text){
|
|
511
|
+
ensureAssistant();
|
|
512
|
+
currentText+=text;
|
|
513
|
+
currentAssistant.textContent=currentText;
|
|
514
|
+
log.scrollTop=log.scrollHeight;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function appendThinking(text){
|
|
518
|
+
ensureAssistant();
|
|
519
|
+
let th=currentAssistant.querySelector('.think');
|
|
520
|
+
if(!th){
|
|
521
|
+
th=document.createElement('div');
|
|
522
|
+
th.className='think';
|
|
523
|
+
th.textContent='[thinking]\n';
|
|
524
|
+
currentAssistant.appendChild(th);
|
|
525
|
+
}
|
|
526
|
+
th.textContent+=text;
|
|
527
|
+
log.scrollTop=log.scrollHeight;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function startTool(name,inputs){
|
|
531
|
+
ensureAssistant();
|
|
532
|
+
const t=document.createElement('div');
|
|
533
|
+
t.className='tool';
|
|
534
|
+
t.textContent='🔧 '+name+'\n'+JSON.stringify(inputs,null,2);
|
|
535
|
+
currentAssistant.appendChild(t);
|
|
536
|
+
log.scrollTop=log.scrollHeight;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function endTool(name,result,permitted){
|
|
540
|
+
ensureAssistant();
|
|
541
|
+
const r=document.createElement('div');
|
|
542
|
+
r.className='tool-result';
|
|
543
|
+
r.textContent=(permitted?'✅':'❌')+' '+result;
|
|
544
|
+
currentAssistant.appendChild(r);
|
|
545
|
+
log.scrollTop=log.scrollHeight;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function showPermission(id,desc){
|
|
549
|
+
ensureAssistant();
|
|
550
|
+
const p=document.createElement('div');
|
|
551
|
+
p.className='perm';
|
|
552
|
+
p.innerHTML='<span>⛔ '+desc+'</span>';
|
|
553
|
+
const yes=document.createElement('button');
|
|
554
|
+
yes.textContent='Approve';
|
|
555
|
+
yes.className='approve';
|
|
556
|
+
yes.onclick=function(){sendPermission(id,true);p.remove();};
|
|
557
|
+
const no=document.createElement('button');
|
|
558
|
+
no.textContent='Deny';
|
|
559
|
+
no.onclick=function(){sendPermission(id,false);p.remove();};
|
|
560
|
+
p.appendChild(yes);
|
|
561
|
+
p.appendChild(no);
|
|
562
|
+
currentAssistant.appendChild(p);
|
|
563
|
+
log.scrollTop=log.scrollHeight;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function sendPermission(id,granted){
|
|
567
|
+
await fetch('/permission',{
|
|
568
|
+
method:'POST',
|
|
569
|
+
headers:{'Content-Type':'application/json'},
|
|
570
|
+
body:JSON.stringify({id:id,granted:granted})
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function sendMessage(){
|
|
575
|
+
const t=inp.value.trim();
|
|
576
|
+
if(!t) return;
|
|
577
|
+
add('user',t);
|
|
578
|
+
inp.value='';
|
|
579
|
+
btn.disabled=true;
|
|
580
|
+
currentAssistant=null;
|
|
581
|
+
currentText='';
|
|
582
|
+
try{
|
|
583
|
+
const resp=await fetch('/chat',{
|
|
584
|
+
method:'POST',
|
|
585
|
+
headers:{'Content-Type':'application/json'},
|
|
586
|
+
body:JSON.stringify({message:t})
|
|
587
|
+
});
|
|
588
|
+
const reader=resp.body.getReader();
|
|
589
|
+
const decoder=new TextDecoder();
|
|
590
|
+
let buf='';
|
|
591
|
+
while(true){
|
|
592
|
+
const chunk=await reader.read();
|
|
593
|
+
if(chunk.done) break;
|
|
594
|
+
buf+=decoder.decode(chunk.value,{stream:true});
|
|
595
|
+
const lines=buf.split('\n');
|
|
596
|
+
buf=lines.pop();
|
|
597
|
+
for(let i=0;i<lines.length;i++){
|
|
598
|
+
const line=lines[i];
|
|
599
|
+
if(!line.startsWith('data: ')) continue;
|
|
600
|
+
let d;
|
|
601
|
+
try{d=JSON.parse(line.slice(6));}catch(_){continue;}
|
|
602
|
+
if(d.type==='text') appendText(d.text);
|
|
603
|
+
else if(d.type==='thinking') appendThinking(d.text);
|
|
604
|
+
else if(d.type==='tool_start') startTool(d.name,d.inputs);
|
|
605
|
+
else if(d.type==='tool_end') endTool(d.name,d.result,d.permitted);
|
|
606
|
+
else if(d.type==='permission') showPermission(d.id,d.description);
|
|
607
|
+
else if(d.type==='turn_done'){
|
|
608
|
+
const meta=document.createElement('div');
|
|
609
|
+
meta.className='meta';
|
|
610
|
+
let txt = 'in:'+d.in+' out:'+d.out;
|
|
611
|
+
if (d.cache_read) txt += ' [cache hit: ' + d.cache_read + ']';
|
|
612
|
+
if (d.cache_write) txt += ' [cache new: ' + d.cache_write + ']';
|
|
613
|
+
meta.textContent=txt;
|
|
614
|
+
ensureAssistant().appendChild(meta);
|
|
615
|
+
}
|
|
616
|
+
else if(d.type==='error') appendText('\n[error] '+d.message);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}catch(err){
|
|
620
|
+
add('assistant','[network] '+err,'').classList.add('err');
|
|
621
|
+
}finally{
|
|
622
|
+
btn.disabled=false;
|
|
623
|
+
inp.focus();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function clearChat(){
|
|
628
|
+
await fetch('/clear',{method:'POST'});
|
|
629
|
+
log.innerHTML='';
|
|
630
|
+
currentAssistant=null;
|
|
631
|
+
currentText='';
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function syncChat(){
|
|
635
|
+
if(btn.disabled) return;
|
|
636
|
+
try{
|
|
637
|
+
const rh = await fetch('/api/chat/history');
|
|
638
|
+
if (rh.ok) {
|
|
639
|
+
const ht = await rh.json();
|
|
640
|
+
const currentMsgs = log.querySelectorAll('.msg').length;
|
|
641
|
+
if (ht.messages.length !== currentMsgs) {
|
|
642
|
+
const wasNearBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 50;
|
|
643
|
+
log.innerHTML='';
|
|
644
|
+
currentAssistant=null;
|
|
645
|
+
currentText='';
|
|
646
|
+
for (const m of ht.messages) {
|
|
647
|
+
if (m.role === 'user') add('user', m.content);
|
|
648
|
+
else if (m.role === 'assistant') {
|
|
649
|
+
let text = typeof m.content === 'string' ? m.content : '';
|
|
650
|
+
if (Array.isArray(m.content)) {
|
|
651
|
+
const tc = m.content.find(c => c.type === 'text');
|
|
652
|
+
if (tc) text = tc.text;
|
|
653
|
+
}
|
|
654
|
+
if (text) add('assistant', text);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if(wasNearBottom) log.scrollTop=log.scrollHeight;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const rp = await fetch('/api/personas');
|
|
661
|
+
if (rp.ok) {
|
|
662
|
+
const jp = await rp.json();
|
|
663
|
+
const sel = document.getElementById('personaSelect');
|
|
664
|
+
sel.innerHTML = jp.personas.map(p => {
|
|
665
|
+
const isSelected = jp.active[p.name] ? 'selected' : '';
|
|
666
|
+
return `<option value="${p.name}" ${isSelected}>${p.name} (${p.role})</option>`;
|
|
667
|
+
}).join('');
|
|
668
|
+
sel.onchange = async (e) => {
|
|
669
|
+
await fetch('/api/personas/activate', {
|
|
670
|
+
method:'POST',
|
|
671
|
+
headers:{'Content-Type':'application/json'},
|
|
672
|
+
body: JSON.stringify({name:e.target.value})
|
|
673
|
+
});
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
}catch(_){}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function loadHist(){ return syncChat(); }
|
|
680
|
+
|
|
681
|
+
inp.addEventListener('keydown',function(e){
|
|
682
|
+
if(e.key==='Enter' && !e.shiftKey){
|
|
683
|
+
e.preventDefault();
|
|
684
|
+
sendMessage();
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
loadHist();
|
|
689
|
+
setInterval(syncChat, 5000);
|
|
690
|
+
</script>
|
|
691
|
+
</body></html>"""
|
|
692
|
+
|
|
693
|
+
# ─────────────────────── Mesa Redonda HTML ──────────────────────────
|
|
694
|
+
RT_PAGE = r"""<!doctype html>
|
|
695
|
+
<html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
696
|
+
<title>Dulus Mesa Redonda</title>
|
|
697
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
698
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
699
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Archivo+Black&display=swap" rel="stylesheet">
|
|
700
|
+
<style>
|
|
701
|
+
:root{
|
|
702
|
+
--bg:#0a0a0a;
|
|
703
|
+
--bg2:#0f0f12;
|
|
704
|
+
--bg3:#15151a;
|
|
705
|
+
--ink:#f0e8df;
|
|
706
|
+
--dim:#6a6470;
|
|
707
|
+
--dim2:#3a3840;
|
|
708
|
+
--accent:#ff6b1f;
|
|
709
|
+
--accent2:#ffb347;
|
|
710
|
+
--mono:'JetBrains Mono',monospace;
|
|
711
|
+
--display:'Archivo Black','Impact',sans-serif;
|
|
712
|
+
--radius:4px;
|
|
713
|
+
}
|
|
714
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
715
|
+
html{scroll-behavior:smooth;font-size:16px}
|
|
716
|
+
body{background:var(--bg);color:var(--ink);font-family:var(--mono);height:100vh;display:flex;flex-direction:column;position:relative}
|
|
717
|
+
::-webkit-scrollbar{width:6px}
|
|
718
|
+
::-webkit-scrollbar-track{background:var(--bg)}
|
|
719
|
+
::-webkit-scrollbar-thumb{background:var(--accent);border-radius:3px}
|
|
720
|
+
|
|
721
|
+
.grid-bg{
|
|
722
|
+
position:fixed;inset:0;pointer-events:none;z-index:0;
|
|
723
|
+
background-image:linear-gradient(rgba(255,107,31,.06) 1px,transparent 1px),
|
|
724
|
+
linear-gradient(90deg,rgba(255,107,31,.06) 1px,transparent 1px);
|
|
725
|
+
background-size:40px 40px;
|
|
726
|
+
mask-image:radial-gradient(ellipse at center,black 30%,transparent 80%);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
header{padding:0 40px;height:64px;background:rgba(10,10,10,.7);backdrop-filter:blur(16px);border-bottom:1px solid rgba(255,107,31,.12);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;position:relative;z-index:100}
|
|
730
|
+
header h1{font-family:var(--display);font-size:18px;letter-spacing:-.02em;color:var(--ink);display:flex;align-items:center;gap:12px}
|
|
731
|
+
header h1::before{content:"▲";font-size:18px;color:#000;background:var(--accent);width:32px;height:32px;display:grid;place-items:center;clip-path:polygon(50% 0%,100% 25%,100% 75%,50% 100%,0% 75%,0% 25%);}
|
|
732
|
+
|
|
733
|
+
header a,header button{background:var(--bg2);color:var(--dim);border:1px solid var(--dim2);padding:6px 12px;border-radius:var(--radius);cursor:pointer;font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;text-decoration:none;transition:background .2s,border-color .2s,color .2s}
|
|
734
|
+
header a:hover,header button:hover{background:rgba(255,107,31,.1);border-color:var(--accent);color:var(--accent)}
|
|
735
|
+
|
|
736
|
+
#setup{padding:30px;display:flex;flex-direction:column;gap:16px;align-items:center;justify-content:center;flex:1;position:relative;z-index:1}
|
|
737
|
+
#setup h2{color:var(--accent);font-family:var(--display);font-size:32px;letter-spacing:-.02em}
|
|
738
|
+
#setup p{color:var(--dim);font-size:13px;letter-spacing:.1em;text-transform:uppercase}
|
|
739
|
+
#setup textarea{width:400px;max-width:90vw;height:120px;background:var(--bg3);border:1px solid var(--dim2);color:var(--ink);padding:12px;border-radius:var(--radius);font-family:var(--mono);font-size:13px;resize:none;outline:none;transition:border-color .2s}
|
|
740
|
+
#setup textarea:focus{border-color:var(--accent)}
|
|
741
|
+
#setup button{background:var(--accent);color:#000;border:none;padding:12px 32px;border-radius:var(--radius);font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .2s}
|
|
742
|
+
#setup button:hover{background:var(--accent2)}
|
|
743
|
+
#grid{display:none;flex:1;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px;padding:24px 40px;overflow-y:auto;position:relative;z-index:1}
|
|
744
|
+
.col{background:var(--bg2);border:1px solid var(--dim2);border-radius:6px;display:flex;flex-direction:column;overflow:hidden}
|
|
745
|
+
.col-head{padding:12px 16px;background:var(--bg3);border-bottom:1px solid var(--dim2);font-size:12px;font-weight:700;color:var(--accent);letter-spacing:.1em;text-transform:uppercase;display:flex;justify-content:space-between;align-items:center}
|
|
746
|
+
.col-head .stop-btn{background:#2a0a0a;color:#ff5a6e;border:1px solid rgba(255,90,110,.4);padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;transition:background .2s,border-color .2s}
|
|
747
|
+
.col-head .stop-btn:hover{background:rgba(255,90,110,.15);border-color:#ff5a6e}
|
|
748
|
+
.col-head .stop-btn:disabled{opacity:.4;cursor:not-allowed}
|
|
749
|
+
.col.stopped{border-color:rgba(255,90,110,.4)}
|
|
750
|
+
.col.stopped .col-head{color:#ff5a6e}
|
|
751
|
+
.col-body{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
|
|
752
|
+
.user-bubble{align-self:flex-end;background:rgba(255,107,31,.1);border:1px solid rgba(255,107,31,.25);padding:10px 14px;border-radius:6px;white-space:pre-wrap;word-wrap:break-word;font-size:13px}
|
|
753
|
+
.agent-bubble{align-self:flex-start;background:var(--bg3);border:1px solid var(--dim2);padding:10px 14px;border-radius:6px;white-space:pre-wrap;word-wrap:break-word;font-size:13px}
|
|
754
|
+
.think{font-size:11px;color:var(--dim);margin-top:6px;padding:6px 10px;border-left:2px solid var(--dim2);background:rgba(0,0,0,.2);white-space:pre-wrap}
|
|
755
|
+
.tool{font-size:11px;color:#a39ca8;margin-top:6px;padding:6px 10px;border-left:2px solid var(--accent);background:rgba(255,107,31,.04);white-space:pre-wrap}
|
|
756
|
+
.meta{font-size:10px;color:var(--dim);margin-top:6px}
|
|
757
|
+
.err{color:#ff5a6e}
|
|
758
|
+
.perm{font-size:12px;color:#ffd166;margin-top:8px;padding:12px;border:1px solid rgba(255,209,102,.25);background:rgba(255,209,102,.1);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
759
|
+
.perm button{background:var(--bg3);color:var(--ink);border:1px solid var(--dim2);padding:6px 14px;border-radius:3px;cursor:pointer;font-weight:700}
|
|
760
|
+
.perm button.approve{background:var(--accent);color:#000;border:none}
|
|
761
|
+
#inputArea{display:flex;gap:10px;padding:16px 40px;background:var(--bg2);border-top:1px solid var(--dim2);position:relative;z-index:100}
|
|
762
|
+
textarea{flex:1;background:var(--bg3);color:var(--ink);border:1px solid var(--dim2);padding:12px;border-radius:var(--radius);font-family:var(--mono);font-size:14px;resize:none;height:64px;outline:none;transition:border-color .2s}
|
|
763
|
+
textarea:focus{border-color:var(--accent)}
|
|
764
|
+
button.send{background:var(--accent);color:#000;border:none;padding:0 24px;border-radius:var(--radius);font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .2s}
|
|
765
|
+
button.send:disabled{opacity:.4;cursor:not-allowed}
|
|
766
|
+
@media(max-width:600px){
|
|
767
|
+
header{padding:10px 20px;height:auto}
|
|
768
|
+
#grid{padding:16px 20px}
|
|
769
|
+
#inputArea{padding:16px 20px}
|
|
770
|
+
}
|
|
771
|
+
</style></head><body>
|
|
772
|
+
<div class="grid-bg"></div>
|
|
773
|
+
<header>
|
|
774
|
+
<h1>DULUS MESA REDONDA</h1>
|
|
775
|
+
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
|
776
|
+
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--dim)">
|
|
777
|
+
<input type="checkbox" id="proactiveToggle" style="accent-color:var(--accent);cursor:pointer">
|
|
778
|
+
<span id="proactiveLabel">Auto-turno</span>
|
|
779
|
+
</label>
|
|
780
|
+
<a href="/">Chat</a>
|
|
781
|
+
<a href="/dashboard">Task Manager</a>
|
|
782
|
+
<button onclick="location.reload()">Reiniciar</button>
|
|
783
|
+
</div>
|
|
784
|
+
</header>
|
|
785
|
+
<div id="setup">
|
|
786
|
+
<h2>Setup</h2>
|
|
787
|
+
<p>Introduce 3 a 5 modelos (uno por linea)</p>
|
|
788
|
+
<textarea id="modelsInput" placeholder="kimi-code/kimi-for-coding kimi-code2/kimi-for-coding kimi-code3/kimi-for-coding"></textarea>
|
|
789
|
+
<button onclick="startRt()">Iniciar</button>
|
|
790
|
+
</div>
|
|
791
|
+
<div id="grid"></div>
|
|
792
|
+
<div id="inputArea" style="display:none">
|
|
793
|
+
<textarea id="inp" placeholder="Mensaje a la mesa... (Enter envia)" autofocus></textarea>
|
|
794
|
+
<button class="send" id="sendBtn" onclick="sendTurn()">SEND</button>
|
|
795
|
+
</div>
|
|
796
|
+
<script>
|
|
797
|
+
let agents=[];
|
|
798
|
+
let active=false;
|
|
799
|
+
let proactiveMode=false;
|
|
800
|
+
let autoRoundsLeft=0;
|
|
801
|
+
|
|
802
|
+
const proactiveToggle=document.getElementById('proactiveToggle');
|
|
803
|
+
proactiveToggle.addEventListener('change',function(){
|
|
804
|
+
proactiveMode=this.checked;
|
|
805
|
+
const lbl=document.getElementById('proactiveLabel');
|
|
806
|
+
lbl.textContent=proactiveMode?'Auto-turno (ON)':'Auto-turno';
|
|
807
|
+
lbl.style.color=proactiveMode?'#00ffa3':'#888';
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
function startRt(){
|
|
811
|
+
const raw=document.getElementById('modelsInput').value.trim().split('\n').filter(function(x){return x.trim();});
|
|
812
|
+
if(raw.length<3||raw.length>5){alert('Necesitas 3 a 5 modelos');return;}
|
|
813
|
+
fetch('/roundtable/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({models:raw})})
|
|
814
|
+
.then(function(r){return r.json();})
|
|
815
|
+
.then(function(j){
|
|
816
|
+
if(!j.ok){alert(j.error);return;}
|
|
817
|
+
agents=j.agents;
|
|
818
|
+
active=true;
|
|
819
|
+
document.getElementById('setup').style.display='none';
|
|
820
|
+
document.getElementById('grid').style.display='grid';
|
|
821
|
+
document.getElementById('inputArea').style.display='flex';
|
|
822
|
+
const grid=document.getElementById('grid');
|
|
823
|
+
grid.innerHTML='';
|
|
824
|
+
agents.forEach(function(a){
|
|
825
|
+
const col=document.createElement('div');
|
|
826
|
+
col.className='col';
|
|
827
|
+
col.id='col-'+a;
|
|
828
|
+
col.innerHTML='<div class="col-head"><span>'+a+'</span><button class="stop-btn" id="stop-'+a+'" onclick="stopAgent(\''+a+'\')">Stop</button></div><div class="col-body"></div>';
|
|
829
|
+
grid.appendChild(col);
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function addUserToAll(msg){
|
|
835
|
+
agents.forEach(function(a){
|
|
836
|
+
const body=document.querySelector('#col-'+a+' .col-body');
|
|
837
|
+
const d=document.createElement('div');
|
|
838
|
+
d.className='user-bubble';
|
|
839
|
+
d.textContent=msg;
|
|
840
|
+
body.appendChild(d);
|
|
841
|
+
body.scrollTop=body.scrollHeight;
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function addUserToAgent(agentId,msg){
|
|
846
|
+
const body=document.querySelector('#col-'+agentId+' .col-body');
|
|
847
|
+
if(!body) return;
|
|
848
|
+
const d=document.createElement('div');
|
|
849
|
+
d.className='user-bubble';
|
|
850
|
+
d.style.borderStyle='dashed';
|
|
851
|
+
d.textContent=msg;
|
|
852
|
+
body.appendChild(d);
|
|
853
|
+
body.scrollTop=body.scrollHeight;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function parseDirectMessage(text){
|
|
857
|
+
const m=text.match(/^\/([a-zA-Z0-9_-]+)\s+(.+)$/);
|
|
858
|
+
if(!m) return null;
|
|
859
|
+
return {agent:m[1], message:m[2].trim()};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function stopAgent(id){
|
|
863
|
+
const btn=document.getElementById('stop-'+id);
|
|
864
|
+
if(btn) btn.disabled=true;
|
|
865
|
+
fetch('/roundtable/stop-agent',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent_id:id})})
|
|
866
|
+
.then(function(r){return r.json();})
|
|
867
|
+
.then(function(j){
|
|
868
|
+
if(!j.ok && btn) btn.disabled=false;
|
|
869
|
+
})
|
|
870
|
+
.catch(function(){
|
|
871
|
+
if(btn) btn.disabled=false;
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function sendPermission(id,granted){
|
|
876
|
+
await fetch('/permission',{
|
|
877
|
+
method:'POST',
|
|
878
|
+
headers:{'Content-Type':'application/json'},
|
|
879
|
+
body:JSON.stringify({id:id,granted:granted})
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function appendToAgent(agent,type,data){
|
|
884
|
+
const col=document.getElementById('col-'+agent);
|
|
885
|
+
if(!col) return;
|
|
886
|
+
const body=col.querySelector('.col-body');
|
|
887
|
+
if(type==='agent_stopped'){
|
|
888
|
+
col.classList.add('stopped');
|
|
889
|
+
const s=document.createElement('div');
|
|
890
|
+
s.className='meta';
|
|
891
|
+
s.style.color='#ff5a6e';
|
|
892
|
+
s.textContent='[detenido por usuario]';
|
|
893
|
+
body.appendChild(s);
|
|
894
|
+
body.scrollTop=body.scrollHeight;
|
|
895
|
+
const btn=document.getElementById('stop-'+agent);
|
|
896
|
+
if(btn) btn.disabled=true;
|
|
897
|
+
if(col._lastBubble) col._lastBubble.dataset.done='1';
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if(type==='agent_done'){
|
|
901
|
+
const btn=document.getElementById('stop-'+agent);
|
|
902
|
+
if(btn) btn.disabled=true;
|
|
903
|
+
}
|
|
904
|
+
if(type==='text'){
|
|
905
|
+
let bubble=col._lastBubble;
|
|
906
|
+
if(!bubble||bubble.dataset.done==='1'){
|
|
907
|
+
bubble=document.createElement('div');
|
|
908
|
+
bubble.className='agent-bubble';
|
|
909
|
+
bubble.dataset.done='0';
|
|
910
|
+
body.appendChild(bubble);
|
|
911
|
+
col._lastBubble=bubble;
|
|
912
|
+
}
|
|
913
|
+
bubble.textContent=(bubble.textContent||'')+data.text;
|
|
914
|
+
body.scrollTop=body.scrollHeight;
|
|
915
|
+
}
|
|
916
|
+
else if(type==='thinking'){
|
|
917
|
+
let th=body.querySelector('.think:last-child');
|
|
918
|
+
if(!th||th.dataset.type!=='thinking'){
|
|
919
|
+
th=document.createElement('div');
|
|
920
|
+
th.className='think';
|
|
921
|
+
th.dataset.type='thinking';
|
|
922
|
+
th.textContent='[thinking]\n';
|
|
923
|
+
body.appendChild(th);
|
|
924
|
+
}
|
|
925
|
+
th.textContent+=data.text;
|
|
926
|
+
body.scrollTop=body.scrollHeight;
|
|
927
|
+
}
|
|
928
|
+
else if(type==='tool_start'){
|
|
929
|
+
const t=document.createElement('div');
|
|
930
|
+
t.className='tool';
|
|
931
|
+
t.textContent='🔧 '+data.name+'\n'+JSON.stringify(data.inputs,null,2);
|
|
932
|
+
body.appendChild(t);
|
|
933
|
+
body.scrollTop=body.scrollHeight;
|
|
934
|
+
}
|
|
935
|
+
else if(type==='tool_end'){
|
|
936
|
+
const r=document.createElement('div');
|
|
937
|
+
r.className='tool';
|
|
938
|
+
r.style.borderLeftColor='#444';
|
|
939
|
+
r.style.background='#111';
|
|
940
|
+
r.textContent=(data.permitted?'✅':'❌')+' '+data.result;
|
|
941
|
+
body.appendChild(r);
|
|
942
|
+
body.scrollTop=body.scrollHeight;
|
|
943
|
+
}
|
|
944
|
+
else if(type==='permission'){
|
|
945
|
+
const p=document.createElement('div');
|
|
946
|
+
p.className='perm';
|
|
947
|
+
p.innerHTML='<span>⛔ '+data.description+'</span>';
|
|
948
|
+
const yes=document.createElement('button');
|
|
949
|
+
yes.textContent='Approve';
|
|
950
|
+
yes.className='approve';
|
|
951
|
+
yes.onclick=function(){sendPermission(data.id,true);p.remove();};
|
|
952
|
+
const no=document.createElement('button');
|
|
953
|
+
no.textContent='Deny';
|
|
954
|
+
no.onclick=function(){sendPermission(data.id,false);p.remove();};
|
|
955
|
+
p.appendChild(yes);
|
|
956
|
+
p.appendChild(no);
|
|
957
|
+
body.appendChild(p);
|
|
958
|
+
body.scrollTop=body.scrollHeight;
|
|
959
|
+
}
|
|
960
|
+
else if(type==='turn_done'){
|
|
961
|
+
const m=document.createElement('div');
|
|
962
|
+
m.className='meta';
|
|
963
|
+
m.textContent='in:'+data.in+' out:'+data.out;
|
|
964
|
+
body.appendChild(m);
|
|
965
|
+
if(col._lastBubble) col._lastBubble.dataset.done='1';
|
|
966
|
+
}
|
|
967
|
+
else if(type==='error'){
|
|
968
|
+
const e=document.createElement('div');
|
|
969
|
+
e.className='agent-bubble';
|
|
970
|
+
e.style.color='#ff6b6b';
|
|
971
|
+
e.textContent='[error] '+data.message;
|
|
972
|
+
body.appendChild(e);
|
|
973
|
+
body.scrollTop=body.scrollHeight;
|
|
974
|
+
}
|
|
975
|
+
else if(type==='agent_done'){
|
|
976
|
+
const btn=document.getElementById('stop-'+agent);
|
|
977
|
+
if(btn) btn.disabled=true;
|
|
978
|
+
if(col._lastBubble) col._lastBubble.dataset.done='1';
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function sendTurnWithMessage(t){
|
|
983
|
+
const inp=document.getElementById('inp');
|
|
984
|
+
const btn=document.getElementById('sendBtn');
|
|
985
|
+
if(!t) return;
|
|
986
|
+
const direct=parseDirectMessage(t);
|
|
987
|
+
if(direct){
|
|
988
|
+
const targetAgent=agents.find(function(a){ return a.toLowerCase()===direct.agent.toLowerCase(); });
|
|
989
|
+
if(!targetAgent){
|
|
990
|
+
alert('Agente no encontrado: '+direct.agent);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
inp.value='';
|
|
994
|
+
btn.disabled=true;
|
|
995
|
+
const stopBtnDirect=document.getElementById('stop-'+targetAgent);
|
|
996
|
+
if(stopBtnDirect) stopBtnDirect.disabled=false;
|
|
997
|
+
const colDirect=document.getElementById('col-'+targetAgent);
|
|
998
|
+
if(colDirect) colDirect.classList.remove('stopped');
|
|
999
|
+
addUserToAgent(targetAgent,'[→ '+targetAgent+'] '+direct.message);
|
|
1000
|
+
try{
|
|
1001
|
+
const resp=await fetch('/roundtable/direct',{
|
|
1002
|
+
method:'POST',
|
|
1003
|
+
headers:{'Content-Type':'application/json'},
|
|
1004
|
+
body:JSON.stringify({agent_id:targetAgent, message:direct.message})
|
|
1005
|
+
});
|
|
1006
|
+
const reader=resp.body.getReader();
|
|
1007
|
+
const decoder=new TextDecoder();
|
|
1008
|
+
let buf='';
|
|
1009
|
+
while(true){
|
|
1010
|
+
const chunk=await reader.read();
|
|
1011
|
+
if(chunk.done) break;
|
|
1012
|
+
buf+=decoder.decode(chunk.value,{stream:true});
|
|
1013
|
+
const lines=buf.split('\n');
|
|
1014
|
+
buf=lines.pop();
|
|
1015
|
+
for(let i=0;i<lines.length;i++){
|
|
1016
|
+
const line=lines[i];
|
|
1017
|
+
if(!line.startsWith('data: ')) continue;
|
|
1018
|
+
let d;
|
|
1019
|
+
try{d=JSON.parse(line.slice(6));}catch(_){continue;}
|
|
1020
|
+
if(d.agent) appendToAgent(d.agent,d.type,d);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}catch(err){
|
|
1024
|
+
alert('[network] '+err);
|
|
1025
|
+
}finally{
|
|
1026
|
+
btn.disabled=false;
|
|
1027
|
+
inp.focus();
|
|
1028
|
+
}
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
inp.value='';
|
|
1032
|
+
btn.disabled=true;
|
|
1033
|
+
agents.forEach(function(a){
|
|
1034
|
+
const stopBtn=document.getElementById('stop-'+a);
|
|
1035
|
+
if(stopBtn) stopBtn.disabled=false;
|
|
1036
|
+
const col=document.getElementById('col-'+a);
|
|
1037
|
+
if(col) col.classList.remove('stopped');
|
|
1038
|
+
});
|
|
1039
|
+
addUserToAll(t);
|
|
1040
|
+
try{
|
|
1041
|
+
const resp=await fetch('/roundtable/chat',{
|
|
1042
|
+
method:'POST',
|
|
1043
|
+
headers:{'Content-Type':'application/json'},
|
|
1044
|
+
body:JSON.stringify({message:t})
|
|
1045
|
+
});
|
|
1046
|
+
const reader=resp.body.getReader();
|
|
1047
|
+
const decoder=new TextDecoder();
|
|
1048
|
+
let buf='';
|
|
1049
|
+
let gotDone=false;
|
|
1050
|
+
while(true){
|
|
1051
|
+
const chunk=await reader.read();
|
|
1052
|
+
if(chunk.done) break;
|
|
1053
|
+
buf+=decoder.decode(chunk.value,{stream:true});
|
|
1054
|
+
const lines=buf.split('\n');
|
|
1055
|
+
buf=lines.pop();
|
|
1056
|
+
for(let i=0;i<lines.length;i++){
|
|
1057
|
+
const line=lines[i];
|
|
1058
|
+
if(!line.startsWith('data: ')) continue;
|
|
1059
|
+
let d;
|
|
1060
|
+
try{d=JSON.parse(line.slice(6));}catch(_){continue;}
|
|
1061
|
+
if(d.type==='done'){gotDone=true;}
|
|
1062
|
+
if(d.agent) appendToAgent(d.agent,d.type,d);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
// if proactive is on and we got a clean done, auto-fire next round
|
|
1066
|
+
if(gotDone && proactiveMode && autoRoundsLeft>0){
|
|
1067
|
+
autoRoundsLeft--;
|
|
1068
|
+
setTimeout(function(){
|
|
1069
|
+
sendTurnWithMessage('Proactive mode active keep working');
|
|
1070
|
+
},800);
|
|
1071
|
+
}
|
|
1072
|
+
}catch(err){
|
|
1073
|
+
alert('[network] '+err);
|
|
1074
|
+
}finally{
|
|
1075
|
+
btn.disabled=false;
|
|
1076
|
+
inp.focus();
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function sendTurn(){
|
|
1081
|
+
const t=document.getElementById('inp').value.trim();
|
|
1082
|
+
if(!t) return;
|
|
1083
|
+
if(proactiveMode){
|
|
1084
|
+
autoRoundsLeft=10; // max 10 auto rounds when user manually triggers
|
|
1085
|
+
}
|
|
1086
|
+
await sendTurnWithMessage(t);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function restoreRt(){
|
|
1090
|
+
try{
|
|
1091
|
+
const r = await fetch('/roundtable/status');
|
|
1092
|
+
const j = await r.json();
|
|
1093
|
+
if(j.active && j.agents && j.agents.length){
|
|
1094
|
+
agents = j.agents;
|
|
1095
|
+
active = true;
|
|
1096
|
+
document.getElementById('setup').style.display='none';
|
|
1097
|
+
document.getElementById('grid').style.display='grid';
|
|
1098
|
+
document.getElementById('inputArea').style.display='flex';
|
|
1099
|
+
const grid = document.getElementById('grid');
|
|
1100
|
+
grid.innerHTML = '';
|
|
1101
|
+
agents.forEach(function(a){
|
|
1102
|
+
const col = document.createElement('div');
|
|
1103
|
+
col.className = 'col';
|
|
1104
|
+
col.id = 'col-' + a;
|
|
1105
|
+
col.innerHTML = '<div class="col-head"><span>' + a + '</span><button class="stop-btn" id="stop-' + a + '" onclick="stopAgent(\'' + a + '\')">Stop</button></div><div class="col-body"></div>';
|
|
1106
|
+
grid.appendChild(col);
|
|
1107
|
+
});
|
|
1108
|
+
if(j.history && j.history.length){
|
|
1109
|
+
j.history.forEach(function(h){
|
|
1110
|
+
if(h.author === 'Usuario'){
|
|
1111
|
+
addUserToAll(h.text);
|
|
1112
|
+
} else {
|
|
1113
|
+
appendToAgent(h.author, 'text', {text: h.text});
|
|
1114
|
+
const col = document.getElementById('col-' + h.author);
|
|
1115
|
+
if(col && col._lastBubble){
|
|
1116
|
+
col._lastBubble.dataset.done = '1';
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}catch(_){}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
document.getElementById('inp').addEventListener('keydown',function(e){
|
|
1126
|
+
if(e.key==='Enter' && !e.shiftKey){
|
|
1127
|
+
e.preventDefault();
|
|
1128
|
+
sendTurn();
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
restoreRt();
|
|
1133
|
+
</script>
|
|
1134
|
+
</body></html>"""
|
|
1135
|
+
|
|
1136
|
+
@app.route("/")
|
|
1137
|
+
def home() -> Response:
|
|
1138
|
+
return Response(CHAT_PAGE, mimetype="text/html")
|
|
1139
|
+
|
|
1140
|
+
@app.route("/roundtable")
|
|
1141
|
+
def roundtable_page() -> Response:
|
|
1142
|
+
return Response(RT_PAGE, mimetype="text/html")
|
|
1143
|
+
|
|
1144
|
+
@app.route("/state")
|
|
1145
|
+
def state_endpoint() -> Response:
|
|
1146
|
+
with _LOCK:
|
|
1147
|
+
hist = [dict(m) for m in (STATE.messages if STATE else [])]
|
|
1148
|
+
model = CONFIG.get("model", "?") if CONFIG else "?"
|
|
1149
|
+
return jsonify(model=model, history=hist)
|
|
1150
|
+
|
|
1151
|
+
@app.route("/clear", methods=["POST"])
|
|
1152
|
+
def clear() -> Response:
|
|
1153
|
+
with _LOCK:
|
|
1154
|
+
if STATE:
|
|
1155
|
+
STATE.messages.clear()
|
|
1156
|
+
return jsonify(ok=True)
|
|
1157
|
+
|
|
1158
|
+
@app.route("/shutdown", methods=["POST"])
|
|
1159
|
+
def shutdown() -> Response:
|
|
1160
|
+
return jsonify(ok=True)
|
|
1161
|
+
|
|
1162
|
+
@app.route("/permission", methods=["POST"])
|
|
1163
|
+
def permission() -> Response:
|
|
1164
|
+
body = request.get_json(silent=True) or {}
|
|
1165
|
+
pid = body.get("id")
|
|
1166
|
+
granted = body.get("granted", False)
|
|
1167
|
+
with _LOCK:
|
|
1168
|
+
item = _PENDING_PERMISSIONS.get(pid)
|
|
1169
|
+
if item is None:
|
|
1170
|
+
return jsonify(error="not found"), 404
|
|
1171
|
+
req, evt = item
|
|
1172
|
+
req.granted = bool(granted)
|
|
1173
|
+
evt.set()
|
|
1174
|
+
return jsonify(ok=True)
|
|
1175
|
+
|
|
1176
|
+
@app.route("/chat", methods=["POST"])
|
|
1177
|
+
def chat() -> Response:
|
|
1178
|
+
body = request.get_json(silent=True) or {}
|
|
1179
|
+
msg = (body.get("message") or "").strip()
|
|
1180
|
+
if not msg:
|
|
1181
|
+
return jsonify(error="empty message"), 400
|
|
1182
|
+
|
|
1183
|
+
# Slash commands: same behavior as the Telegram bridge —
|
|
1184
|
+
# run via REPL's _handle_slash_callback, capture stdout,
|
|
1185
|
+
# stream output back as text events.
|
|
1186
|
+
if msg.startswith("/") and len(msg) > 1:
|
|
1187
|
+
def generate_slash():
|
|
1188
|
+
yield 'data: {"type":"start"}\n\n'
|
|
1189
|
+
try:
|
|
1190
|
+
output, assistant_reply = _run_slash_command(msg)
|
|
1191
|
+
if output:
|
|
1192
|
+
yield f"data: {json.dumps({'type':'text','text':output})}\n\n"
|
|
1193
|
+
if assistant_reply:
|
|
1194
|
+
sep = "\n\n" if output else ""
|
|
1195
|
+
yield f"data: {json.dumps({'type':'text','text':sep + assistant_reply})}\n\n"
|
|
1196
|
+
except Exception as e:
|
|
1197
|
+
yield f'data: {json.dumps({"type":"error","message":f"{type(e).__name__}: {e}"})}\n\n'
|
|
1198
|
+
yield 'data: {"type":"done"}\n\n'
|
|
1199
|
+
return Response(
|
|
1200
|
+
stream_with_context(generate_slash()),
|
|
1201
|
+
mimetype="text/event-stream",
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
def generate():
|
|
1205
|
+
q: queue.Queue = queue.Queue(maxsize=512)
|
|
1206
|
+
exc_holder = [None]
|
|
1207
|
+
|
|
1208
|
+
def producer():
|
|
1209
|
+
try:
|
|
1210
|
+
for ev in _run_agent_mirror(msg):
|
|
1211
|
+
result = _event_to_dict(ev)
|
|
1212
|
+
if result is None:
|
|
1213
|
+
continue
|
|
1214
|
+
if isinstance(result, tuple):
|
|
1215
|
+
payload, evt = result
|
|
1216
|
+
q.put(payload)
|
|
1217
|
+
evt.wait(timeout=300)
|
|
1218
|
+
_PENDING_PERMISSIONS.pop(payload.get("id"), None)
|
|
1219
|
+
continue
|
|
1220
|
+
q.put(result)
|
|
1221
|
+
except Exception as e:
|
|
1222
|
+
exc_holder[0] = e
|
|
1223
|
+
finally:
|
|
1224
|
+
q.put(None)
|
|
1225
|
+
|
|
1226
|
+
t = threading.Thread(target=producer, daemon=True)
|
|
1227
|
+
t.start()
|
|
1228
|
+
|
|
1229
|
+
yield 'data: {"type":"start"}\n\n'
|
|
1230
|
+
|
|
1231
|
+
while True:
|
|
1232
|
+
item = q.get()
|
|
1233
|
+
if item is None:
|
|
1234
|
+
break
|
|
1235
|
+
yield f"data: {json.dumps(item)}\n\n"
|
|
1236
|
+
|
|
1237
|
+
if exc_holder[0]:
|
|
1238
|
+
err = exc_holder[0]
|
|
1239
|
+
yield f'data: {json.dumps({"type":"error","message":f"{type(err).__name__}: {err}"})}\n\n'
|
|
1240
|
+
|
|
1241
|
+
yield 'data: {"type":"done"}\n\n'
|
|
1242
|
+
|
|
1243
|
+
return Response(
|
|
1244
|
+
stream_with_context(generate()),
|
|
1245
|
+
mimetype="text/event-stream",
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
# ── Roundtable endpoints ─────────────────────────────────────────────
|
|
1249
|
+
|
|
1250
|
+
@app.route("/roundtable/start", methods=["POST"])
|
|
1251
|
+
def roundtable_start() -> Response:
|
|
1252
|
+
body = request.get_json(silent=True) or {}
|
|
1253
|
+
models = body.get("models", [])
|
|
1254
|
+
if not (3 <= len(models) <= 5):
|
|
1255
|
+
return jsonify(ok=False, error="Necesitas 3 a 5 modelos"), 400
|
|
1256
|
+
|
|
1257
|
+
with ROUNDTABLE_LOCK:
|
|
1258
|
+
ROUNDTABLE_AGENTS.clear()
|
|
1259
|
+
ROUNDTABLE_HISTORY.clear()
|
|
1260
|
+
for i, model in enumerate(models):
|
|
1261
|
+
letter = chr(65 + i)
|
|
1262
|
+
ROUNDTABLE_AGENTS.append(RoundtableAgent(letter, model.strip()))
|
|
1263
|
+
|
|
1264
|
+
return jsonify(ok=True, agents=[a.id for a in ROUNDTABLE_AGENTS])
|
|
1265
|
+
|
|
1266
|
+
@app.route("/roundtable/chat", methods=["POST"])
|
|
1267
|
+
def roundtable_chat() -> Response:
|
|
1268
|
+
body = request.get_json(silent=True) or {}
|
|
1269
|
+
msg = (body.get("message") or "").strip()
|
|
1270
|
+
if not msg:
|
|
1271
|
+
return jsonify(error="empty message"), 400
|
|
1272
|
+
|
|
1273
|
+
with ROUNDTABLE_LOCK:
|
|
1274
|
+
agents = list(ROUNDTABLE_AGENTS)
|
|
1275
|
+
if not agents:
|
|
1276
|
+
return jsonify(error="no roundtable active"), 400
|
|
1277
|
+
|
|
1278
|
+
# Slash commands: run once via REPL handler, broadcast the
|
|
1279
|
+
# output to every agent column. Same pattern as Telegram bridge.
|
|
1280
|
+
if msg.startswith("/") and len(msg) > 1:
|
|
1281
|
+
def generate_slash_rt():
|
|
1282
|
+
yield 'data: {"type":"start"}\n\n'
|
|
1283
|
+
try:
|
|
1284
|
+
output, assistant_reply = _run_slash_command(msg)
|
|
1285
|
+
chunks = []
|
|
1286
|
+
if output:
|
|
1287
|
+
chunks.append(output)
|
|
1288
|
+
if assistant_reply:
|
|
1289
|
+
chunks.append(assistant_reply)
|
|
1290
|
+
full = "\n\n".join(chunks) if chunks else f"✅ {msg.split()[0]} executed."
|
|
1291
|
+
for ag in agents:
|
|
1292
|
+
yield f"data: {json.dumps({'type':'text','text':full,'agent':ag.id})}\n\n"
|
|
1293
|
+
yield f"data: {json.dumps({'type':'agent_done','agent':ag.id,'text':full})}\n\n"
|
|
1294
|
+
except Exception as e:
|
|
1295
|
+
err = f"{type(e).__name__}: {e}"
|
|
1296
|
+
for ag in agents:
|
|
1297
|
+
yield f"data: {json.dumps({'type':'error','agent':ag.id,'message':err})}\n\n"
|
|
1298
|
+
yield 'data: {"type":"done"}\n\n'
|
|
1299
|
+
return Response(
|
|
1300
|
+
stream_with_context(generate_slash_rt()),
|
|
1301
|
+
mimetype="text/event-stream",
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
# Snapshot history BEFORE this turn, then add user message
|
|
1305
|
+
msg = _sanitize_for_api(msg)
|
|
1306
|
+
with ROUNDTABLE_LOCK:
|
|
1307
|
+
history_snapshot = list(ROUNDTABLE_HISTORY)
|
|
1308
|
+
ROUNDTABLE_HISTORY.append(("Usuario", msg))
|
|
1309
|
+
|
|
1310
|
+
def generate():
|
|
1311
|
+
q: queue.Queue = queue.Queue(maxsize=1024)
|
|
1312
|
+
active_flags = [True] * len(agents)
|
|
1313
|
+
agent_results: dict[str, str] = {}
|
|
1314
|
+
|
|
1315
|
+
def run_one(idx: int):
|
|
1316
|
+
try:
|
|
1317
|
+
_run_agent_for_roundtable(agents[idx], msg, history_snapshot, q)
|
|
1318
|
+
finally:
|
|
1319
|
+
active_flags[idx] = False
|
|
1320
|
+
|
|
1321
|
+
threads = [
|
|
1322
|
+
threading.Thread(target=run_one, args=(i,), daemon=True)
|
|
1323
|
+
for i in range(len(agents))
|
|
1324
|
+
]
|
|
1325
|
+
for t in threads:
|
|
1326
|
+
t.start()
|
|
1327
|
+
|
|
1328
|
+
yield 'data: {"type":"start"}\n\n'
|
|
1329
|
+
|
|
1330
|
+
while any(active_flags) or not q.empty():
|
|
1331
|
+
try:
|
|
1332
|
+
item = q.get(timeout=0.2)
|
|
1333
|
+
except queue.Empty:
|
|
1334
|
+
continue
|
|
1335
|
+
if item.get("type") == "agent_done":
|
|
1336
|
+
agent_results[item["agent"]] = item.get("text", "")
|
|
1337
|
+
yield f"data: {json.dumps(item)}\n\n"
|
|
1338
|
+
|
|
1339
|
+
# All done — save responses to global history for next turn
|
|
1340
|
+
with ROUNDTABLE_LOCK:
|
|
1341
|
+
for agent in agents:
|
|
1342
|
+
text = agent_results.get(agent.id, "")
|
|
1343
|
+
text = _sanitize_for_api(text)
|
|
1344
|
+
if text:
|
|
1345
|
+
ROUNDTABLE_HISTORY.append((agent.id, text))
|
|
1346
|
+
|
|
1347
|
+
yield 'data: {"type":"done"}\n\n'
|
|
1348
|
+
|
|
1349
|
+
return Response(
|
|
1350
|
+
stream_with_context(generate()),
|
|
1351
|
+
mimetype="text/event-stream",
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
@app.route("/roundtable/stop", methods=["POST"])
|
|
1355
|
+
def roundtable_stop() -> Response:
|
|
1356
|
+
with ROUNDTABLE_LOCK:
|
|
1357
|
+
ROUNDTABLE_AGENTS.clear()
|
|
1358
|
+
return jsonify(ok=True)
|
|
1359
|
+
|
|
1360
|
+
@app.route("/roundtable/stop-agent", methods=["POST"])
|
|
1361
|
+
def roundtable_stop_agent() -> Response:
|
|
1362
|
+
body = request.get_json(silent=True) or {}
|
|
1363
|
+
agent_id = body.get("agent_id", "").strip()
|
|
1364
|
+
if not agent_id:
|
|
1365
|
+
return jsonify(ok=False, error="missing agent_id"), 400
|
|
1366
|
+
with _STOP_EVENTS_LOCK:
|
|
1367
|
+
evt = _AGENT_STOP_EVENTS.get(agent_id)
|
|
1368
|
+
if evt is None:
|
|
1369
|
+
return jsonify(ok=False, error="agent not running"), 404
|
|
1370
|
+
evt.set()
|
|
1371
|
+
return jsonify(ok=True)
|
|
1372
|
+
|
|
1373
|
+
@app.route("/roundtable/status", methods=["GET"])
|
|
1374
|
+
def roundtable_status() -> Response:
|
|
1375
|
+
with ROUNDTABLE_LOCK:
|
|
1376
|
+
active = len(ROUNDTABLE_AGENTS) > 0
|
|
1377
|
+
agents = [a.id for a in ROUNDTABLE_AGENTS]
|
|
1378
|
+
history = [{"author": h[0], "text": h[1]} for h in ROUNDTABLE_HISTORY]
|
|
1379
|
+
return jsonify(active=active, agents=agents, history=history)
|
|
1380
|
+
|
|
1381
|
+
@app.route("/roundtable/direct", methods=["POST"])
|
|
1382
|
+
def roundtable_direct() -> Response:
|
|
1383
|
+
body = request.get_json(silent=True) or {}
|
|
1384
|
+
agent_id = (body.get("agent_id") or "").strip()
|
|
1385
|
+
msg = (body.get("message") or "").strip()
|
|
1386
|
+
if not agent_id or not msg:
|
|
1387
|
+
return jsonify(error="agent_id and message required"), 400
|
|
1388
|
+
|
|
1389
|
+
with ROUNDTABLE_LOCK:
|
|
1390
|
+
target = None
|
|
1391
|
+
for a in ROUNDTABLE_AGENTS:
|
|
1392
|
+
if a.id.lower() == agent_id.lower():
|
|
1393
|
+
target = a
|
|
1394
|
+
break
|
|
1395
|
+
if target is None:
|
|
1396
|
+
return jsonify(error="agent not found"), 404
|
|
1397
|
+
|
|
1398
|
+
msg = _sanitize_for_api(msg)
|
|
1399
|
+
with ROUNDTABLE_LOCK:
|
|
1400
|
+
history_snapshot = list(ROUNDTABLE_HISTORY)
|
|
1401
|
+
ROUNDTABLE_HISTORY.append(("Usuario", f"[{target.id}] {msg}"))
|
|
1402
|
+
|
|
1403
|
+
def generate():
|
|
1404
|
+
q: queue.Queue = queue.Queue(maxsize=1024)
|
|
1405
|
+
final_text = [""]
|
|
1406
|
+
|
|
1407
|
+
def run_one():
|
|
1408
|
+
try:
|
|
1409
|
+
_run_agent_for_roundtable(target, msg, history_snapshot, q)
|
|
1410
|
+
finally:
|
|
1411
|
+
q.put(None)
|
|
1412
|
+
|
|
1413
|
+
t = threading.Thread(target=run_one, daemon=True)
|
|
1414
|
+
t.start()
|
|
1415
|
+
|
|
1416
|
+
yield 'data: {"type":"start"}\n\n'
|
|
1417
|
+
|
|
1418
|
+
while True:
|
|
1419
|
+
try:
|
|
1420
|
+
item = q.get(timeout=0.5)
|
|
1421
|
+
except queue.Empty:
|
|
1422
|
+
continue
|
|
1423
|
+
if item is None:
|
|
1424
|
+
break
|
|
1425
|
+
if item.get("type") == "agent_done":
|
|
1426
|
+
final_text[0] = item.get("text", "")
|
|
1427
|
+
yield f"data: {json.dumps(item)}\n\n"
|
|
1428
|
+
|
|
1429
|
+
with ROUNDTABLE_LOCK:
|
|
1430
|
+
text = _sanitize_for_api(final_text[0])
|
|
1431
|
+
if text:
|
|
1432
|
+
ROUNDTABLE_HISTORY.append((target.id, text))
|
|
1433
|
+
|
|
1434
|
+
yield 'data: {"type":"done"}\n\n'
|
|
1435
|
+
|
|
1436
|
+
return Response(
|
|
1437
|
+
stream_with_context(generate()),
|
|
1438
|
+
mimetype="text/event-stream",
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
# ── DULUS 2 UNIFIED ENDPOINTS ──
|
|
1442
|
+
|
|
1443
|
+
@app.route("/api/events")
|
|
1444
|
+
def api_events():
|
|
1445
|
+
def generate():
|
|
1446
|
+
q = queue.Queue(maxsize=100)
|
|
1447
|
+
_add_sse_client(q)
|
|
1448
|
+
yield f"event: connected\ndata: {json.dumps({'message':'Dulus SSE active'})}\n\n"
|
|
1449
|
+
try:
|
|
1450
|
+
while True:
|
|
1451
|
+
try:
|
|
1452
|
+
msg = q.get(timeout=30)
|
|
1453
|
+
yield msg
|
|
1454
|
+
except queue.Empty:
|
|
1455
|
+
yield ":\n\n"
|
|
1456
|
+
finally:
|
|
1457
|
+
_remove_sse_client(q)
|
|
1458
|
+
return Response(stream_with_context(generate()), mimetype="text/event-stream")
|
|
1459
|
+
|
|
1460
|
+
@app.route("/api/health")
|
|
1461
|
+
def api_health():
|
|
1462
|
+
return jsonify({"status": "ok", "agent": "Dulus", "mode": "proactive", "version": "2026.04.26"})
|
|
1463
|
+
|
|
1464
|
+
@app.route("/api/tasks", methods=["GET"])
|
|
1465
|
+
def get_api_tasks():
|
|
1466
|
+
tasks = task_list()
|
|
1467
|
+
return jsonify([t.to_dict() for t in tasks])
|
|
1468
|
+
|
|
1469
|
+
@app.route("/api/context", methods=["GET"])
|
|
1470
|
+
def api_context():
|
|
1471
|
+
return jsonify(build_context())
|
|
1472
|
+
|
|
1473
|
+
@app.route("/api/context/compact", methods=["GET"])
|
|
1474
|
+
def api_context_compact():
|
|
1475
|
+
return Response(get_compact_context(), mimetype="text/plain")
|
|
1476
|
+
|
|
1477
|
+
@app.route("/api/chat/history", methods=["GET"])
|
|
1478
|
+
def api_chat_history():
|
|
1479
|
+
msgs = []
|
|
1480
|
+
if STATE and hasattr(STATE, "messages"):
|
|
1481
|
+
for m in STATE.messages:
|
|
1482
|
+
msgs.append({"role": m.get("role", ""), "content": m.get("content", "")})
|
|
1483
|
+
return jsonify({"messages": msgs})
|
|
1484
|
+
|
|
1485
|
+
@app.route("/api/smart-context", methods=["GET"])
|
|
1486
|
+
def api_smart_context():
|
|
1487
|
+
return jsonify(build_smart_context())
|
|
1488
|
+
|
|
1489
|
+
@app.route("/api/smart-context/compact", methods=["POST"])
|
|
1490
|
+
def api_smart_context_compact():
|
|
1491
|
+
from backend.context import force_compaction
|
|
1492
|
+
return jsonify(force_compaction())
|
|
1493
|
+
|
|
1494
|
+
@app.route("/api/quick-message", methods=["POST"])
|
|
1495
|
+
def api_quick_message():
|
|
1496
|
+
body = request.get_json(silent=True) or {}
|
|
1497
|
+
msg = (body.get("message") or "").strip()
|
|
1498
|
+
if not msg:
|
|
1499
|
+
return jsonify(error="empty message"), 400
|
|
1500
|
+
|
|
1501
|
+
def run_blind():
|
|
1502
|
+
try:
|
|
1503
|
+
for event in _run_agent_mirror(msg):
|
|
1504
|
+
from agent import PermissionRequest
|
|
1505
|
+
if isinstance(event, PermissionRequest):
|
|
1506
|
+
# Auto-approve silently for background quick messages
|
|
1507
|
+
event.granted = True
|
|
1508
|
+
except Exception as e:
|
|
1509
|
+
import traceback
|
|
1510
|
+
traceback.print_exc()
|
|
1511
|
+
|
|
1512
|
+
threading.Thread(target=run_blind, daemon=True).start()
|
|
1513
|
+
return jsonify(ok=True)
|
|
1514
|
+
|
|
1515
|
+
@app.route("/api/agents", methods=["GET"])
|
|
1516
|
+
def api_agents():
|
|
1517
|
+
return jsonify(build_context().get("agents", []))
|
|
1518
|
+
|
|
1519
|
+
@app.route("/api/personas", methods=["GET"])
|
|
1520
|
+
def api_get_personas():
|
|
1521
|
+
return jsonify({"personas": get_all_personas(), "active": get_active_persona()})
|
|
1522
|
+
|
|
1523
|
+
@app.route("/api/personas/active", methods=["GET"])
|
|
1524
|
+
def api_personas_active():
|
|
1525
|
+
return jsonify(get_active_persona())
|
|
1526
|
+
|
|
1527
|
+
@app.route("/api/personas/<pid>", methods=["GET"])
|
|
1528
|
+
def api_get_persona_id(pid):
|
|
1529
|
+
p = get_persona(pid)
|
|
1530
|
+
if p: return jsonify(p)
|
|
1531
|
+
return jsonify(error="Not found"), 404
|
|
1532
|
+
|
|
1533
|
+
@app.route("/api/personas", methods=["POST"])
|
|
1534
|
+
def api_create_persona():
|
|
1535
|
+
data = request.get_json(silent=True) or {}
|
|
1536
|
+
r = create_persona(data)
|
|
1537
|
+
broadcast_event("persona_created", r)
|
|
1538
|
+
return jsonify(r), 201
|
|
1539
|
+
|
|
1540
|
+
@app.route("/api/tasks", methods=["POST"])
|
|
1541
|
+
def api_create_task():
|
|
1542
|
+
data = request.get_json(silent=True) or {}
|
|
1543
|
+
t = task_create(
|
|
1544
|
+
subject=data.get("subject", "New Task"),
|
|
1545
|
+
description=data.get("description", data.get("metadata", {}).get("description", "")),
|
|
1546
|
+
metadata=data.get("metadata", {}),
|
|
1547
|
+
)
|
|
1548
|
+
result = t.to_dict()
|
|
1549
|
+
broadcast_event("task_created", result)
|
|
1550
|
+
return jsonify(result), 201
|
|
1551
|
+
|
|
1552
|
+
@app.route("/api/tasks/<tid>", methods=["POST"])
|
|
1553
|
+
def api_update_task(tid):
|
|
1554
|
+
data = request.get_json(silent=True) or {}
|
|
1555
|
+
t, fields = task_update(
|
|
1556
|
+
task_id=tid,
|
|
1557
|
+
subject=data.get("subject"),
|
|
1558
|
+
description=data.get("description"),
|
|
1559
|
+
status=data.get("status"),
|
|
1560
|
+
owner=data.get("owner"),
|
|
1561
|
+
metadata=data.get("metadata"),
|
|
1562
|
+
)
|
|
1563
|
+
if t:
|
|
1564
|
+
result = t.to_dict()
|
|
1565
|
+
broadcast_event("task_updated", result)
|
|
1566
|
+
return jsonify(result)
|
|
1567
|
+
return jsonify(error="Not found"), 404
|
|
1568
|
+
|
|
1569
|
+
@app.route("/api/plugins", methods=["GET"])
|
|
1570
|
+
def api_get_plugins():
|
|
1571
|
+
import os
|
|
1572
|
+
user_plugins_dir = Path(os.path.expanduser("~")) / ".dulus" / "plugins"
|
|
1573
|
+
plugins = []
|
|
1574
|
+
if user_plugins_dir.exists():
|
|
1575
|
+
for d in sorted(user_plugins_dir.iterdir()):
|
|
1576
|
+
if d.is_dir() and not d.name.startswith(".") and not d.name.startswith("__"):
|
|
1577
|
+
plugins.append({
|
|
1578
|
+
"name": d.name,
|
|
1579
|
+
"status": "enabled",
|
|
1580
|
+
"source": "user",
|
|
1581
|
+
"path": str(d),
|
|
1582
|
+
})
|
|
1583
|
+
# Also include any from dulus2's hot-reload system
|
|
1584
|
+
try:
|
|
1585
|
+
load_all_plugins()
|
|
1586
|
+
for p in get_plugin_info():
|
|
1587
|
+
if not any(ep["name"] == p["name"] for ep in plugins):
|
|
1588
|
+
plugins.append(p)
|
|
1589
|
+
except Exception:
|
|
1590
|
+
pass
|
|
1591
|
+
return jsonify({"plugins": plugins, "count": len(plugins)})
|
|
1592
|
+
|
|
1593
|
+
@app.route("/api/plugins/status", methods=["GET"])
|
|
1594
|
+
def api_plugins_status():
|
|
1595
|
+
return jsonify(watcher_status())
|
|
1596
|
+
|
|
1597
|
+
@app.route("/api/plugins/reload", methods=["POST"])
|
|
1598
|
+
def api_plugins_reload():
|
|
1599
|
+
data = request.get_json(silent=True) or {}
|
|
1600
|
+
name = data.get("name")
|
|
1601
|
+
if name:
|
|
1602
|
+
from backend.plugins import PLUGINS_DIR
|
|
1603
|
+
r = reload_plugin(PLUGINS_DIR / f"{name}.py")
|
|
1604
|
+
dr = {"name": r.get("name", name), "version": r.get("version", "?"), "status": r.get("status", "?")}
|
|
1605
|
+
broadcast_event("plugin_reloaded", dr)
|
|
1606
|
+
return jsonify(dr)
|
|
1607
|
+
else:
|
|
1608
|
+
load_all_plugins()
|
|
1609
|
+
inf = get_plugin_info()
|
|
1610
|
+
broadcast_event("plugins_reloaded", {"count": len(inf)})
|
|
1611
|
+
return jsonify({"plugins": inf})
|
|
1612
|
+
|
|
1613
|
+
# ── Personas activate ──
|
|
1614
|
+
@app.route("/api/personas/activate", methods=["POST"])
|
|
1615
|
+
def api_personas_activate():
|
|
1616
|
+
data = request.get_json(silent=True) or {}
|
|
1617
|
+
pid = data.get("id")
|
|
1618
|
+
if not pid:
|
|
1619
|
+
return jsonify(error="Missing persona id"), 400
|
|
1620
|
+
result = set_active_persona(pid)
|
|
1621
|
+
if result:
|
|
1622
|
+
broadcast_event("persona_activated", result)
|
|
1623
|
+
return jsonify({"activated": True, "persona": result})
|
|
1624
|
+
return jsonify(error="Persona not found"), 404
|
|
1625
|
+
|
|
1626
|
+
# ── Marketplace ──
|
|
1627
|
+
@app.route("/api/marketplace", methods=["GET"])
|
|
1628
|
+
def api_marketplace():
|
|
1629
|
+
q = request.args.get("q", "")
|
|
1630
|
+
tag = request.args.get("tag", "")
|
|
1631
|
+
return jsonify({"plugins": search_plugins(q, tag)})
|
|
1632
|
+
|
|
1633
|
+
@app.route("/api/marketplace/stats", methods=["GET"])
|
|
1634
|
+
def api_marketplace_stats():
|
|
1635
|
+
return jsonify(marketplace_stats())
|
|
1636
|
+
|
|
1637
|
+
@app.route("/api/marketplace/install", methods=["POST"])
|
|
1638
|
+
def api_marketplace_install():
|
|
1639
|
+
data = request.get_json(silent=True) or {}
|
|
1640
|
+
plugin_id = data.get("id")
|
|
1641
|
+
if not plugin_id:
|
|
1642
|
+
return jsonify(error="Missing plugin id"), 400
|
|
1643
|
+
result = install_plugin(plugin_id)
|
|
1644
|
+
if result:
|
|
1645
|
+
broadcast_event("marketplace_install", result)
|
|
1646
|
+
return jsonify({"installed": True, "plugin": result})
|
|
1647
|
+
return jsonify(error="Plugin not found"), 404
|
|
1648
|
+
|
|
1649
|
+
@app.route("/api/marketplace/uninstall", methods=["POST"])
|
|
1650
|
+
def api_marketplace_uninstall():
|
|
1651
|
+
data = request.get_json(silent=True) or {}
|
|
1652
|
+
plugin_id = data.get("id")
|
|
1653
|
+
if not plugin_id:
|
|
1654
|
+
return jsonify(error="Missing plugin id"), 400
|
|
1655
|
+
result = uninstall_plugin(plugin_id)
|
|
1656
|
+
if result:
|
|
1657
|
+
broadcast_event("marketplace_uninstall", result)
|
|
1658
|
+
return jsonify({"uninstalled": True, "plugin": result})
|
|
1659
|
+
return jsonify(error="Plugin not found"), 404
|
|
1660
|
+
|
|
1661
|
+
# ── MemPalace ──
|
|
1662
|
+
@app.route("/api/mempalace", methods=["GET"])
|
|
1663
|
+
def api_mempalace():
|
|
1664
|
+
try:
|
|
1665
|
+
from backend.mempalace_bridge import load_cache, get_mempalace_compact_text
|
|
1666
|
+
data = load_cache()
|
|
1667
|
+
data["compact_text"] = get_mempalace_compact_text()
|
|
1668
|
+
return jsonify(data)
|
|
1669
|
+
except Exception as e:
|
|
1670
|
+
return jsonify(error=f"MemPalace error: {e}"), 500
|
|
1671
|
+
|
|
1672
|
+
# ── Themes ──
|
|
1673
|
+
@app.route("/api/themes", methods=["GET"])
|
|
1674
|
+
def api_themes():
|
|
1675
|
+
try:
|
|
1676
|
+
from gui.themes import THEMES
|
|
1677
|
+
theme_list = {name: f"{t['accent']} accent, {t['bg']} bg" for name, t in THEMES.items()}
|
|
1678
|
+
return jsonify({"themes": theme_list})
|
|
1679
|
+
except Exception:
|
|
1680
|
+
return jsonify({"themes": {}})
|
|
1681
|
+
|
|
1682
|
+
@app.route("/api/themes/<theme_name>/css", methods=["GET"])
|
|
1683
|
+
def api_theme_css(theme_name):
|
|
1684
|
+
try:
|
|
1685
|
+
from gui.themes import THEMES
|
|
1686
|
+
t = THEMES.get(theme_name)
|
|
1687
|
+
if not t:
|
|
1688
|
+
return Response("", mimetype="text/css")
|
|
1689
|
+
css = ":root{\n"
|
|
1690
|
+
css += f" --bg:{t['bg']};\n"
|
|
1691
|
+
css += f" --bg2:{t['card']};\n"
|
|
1692
|
+
css += f" --bg3:{t.get('code_bg', t['card'])};\n"
|
|
1693
|
+
css += f" --ink:{t['text']};\n"
|
|
1694
|
+
css += f" --dim:{t['dim']};\n"
|
|
1695
|
+
css += f" --dim2:{t['border']};\n"
|
|
1696
|
+
css += f" --accent:{t['accent']};\n"
|
|
1697
|
+
css += f" --accent2:{t.get('accent_hover', t['accent'])};\n"
|
|
1698
|
+
css += f" --green:{t.get('success', '#4caf50')};\n"
|
|
1699
|
+
css += f" --red:{t.get('error', '#ff6b6b')};\n"
|
|
1700
|
+
css += f" --yellow:{t.get('warning', '#FFC107')};\n"
|
|
1701
|
+
css += f" --blue:{t['accent']};\n"
|
|
1702
|
+
css += "}\n"
|
|
1703
|
+
return Response(css, mimetype="text/css")
|
|
1704
|
+
except Exception:
|
|
1705
|
+
return Response("", mimetype="text/css")
|
|
1706
|
+
|
|
1707
|
+
# ── Dashboard static serving ──
|
|
1708
|
+
@app.route("/dashboard")
|
|
1709
|
+
@app.route("/dashboard/")
|
|
1710
|
+
def dashboard_page():
|
|
1711
|
+
target = DASHBOARD_DIR / "index.html"
|
|
1712
|
+
if target.exists():
|
|
1713
|
+
return Response(target.read_bytes(), mimetype="text/html")
|
|
1714
|
+
return "Dashboard not found", 404
|
|
1715
|
+
|
|
1716
|
+
@app.route("/dashboard/<path:filepath>")
|
|
1717
|
+
def dashboard_static(filepath):
|
|
1718
|
+
target = DASHBOARD_DIR / filepath
|
|
1719
|
+
if target.exists() and target.is_file():
|
|
1720
|
+
ctype = "text/html"
|
|
1721
|
+
if filepath.endswith(".css"): ctype = "text/css"
|
|
1722
|
+
elif filepath.endswith(".js"): ctype = "application/javascript"
|
|
1723
|
+
elif filepath.endswith(".json"): ctype = "application/json"
|
|
1724
|
+
elif filepath.endswith(".png"): ctype = "image/png"
|
|
1725
|
+
elif filepath.endswith(".svg"): ctype = "image/svg+xml"
|
|
1726
|
+
return Response(target.read_bytes(), mimetype=ctype)
|
|
1727
|
+
return "Not found", 404
|
|
1728
|
+
|
|
1729
|
+
return app
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def start(state: AgentState, config: dict, port: int = 5000, open_browser: bool = False) -> bool:
|
|
1733
|
+
global STATE, CONFIG, _SERVER_THREAD, _SERVER_PORT, _WERKZEUG_SERVER
|
|
1734
|
+
if _SERVER_THREAD and _SERVER_THREAD.is_alive():
|
|
1735
|
+
return False
|
|
1736
|
+
STATE = state
|
|
1737
|
+
CONFIG = config
|
|
1738
|
+
_SERVER_PORT = port
|
|
1739
|
+
app = create_app()
|
|
1740
|
+
if open_browser:
|
|
1741
|
+
threading.Timer(1.0, lambda: webbrowser.open(f"http://127.0.0.1:{port}/")).start()
|
|
1742
|
+
|
|
1743
|
+
from werkzeug.serving import make_server
|
|
1744
|
+
|
|
1745
|
+
_WERKZEUG_SERVER = make_server("0.0.0.0", port, app, threaded=True)
|
|
1746
|
+
_SERVER_THREAD = threading.Thread(target=_WERKZEUG_SERVER.serve_forever, daemon=True)
|
|
1747
|
+
_SERVER_THREAD.start()
|
|
1748
|
+
return True
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def stop() -> None:
|
|
1752
|
+
global _SERVER_THREAD, _WERKZEUG_SERVER
|
|
1753
|
+
srv = _WERKZEUG_SERVER
|
|
1754
|
+
if srv is not None:
|
|
1755
|
+
srv.shutdown()
|
|
1756
|
+
_SERVER_THREAD = None
|
|
1757
|
+
_WERKZEUG_SERVER = None
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def is_running() -> bool:
|
|
1761
|
+
return _SERVER_THREAD is not None and _SERVER_THREAD.is_alive()
|