@danieltmn/tamnora 0.21.1 → 0.22.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/CHANGELOG.md +15 -0
- package/README.md +10 -6
- package/SECURITY.md +13 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +56 -2
- package/harness/packages/core/src/hooks/index.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +181 -0
- package/harness/packages/core/src/plugins/index.js +6 -4
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +54 -53
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/hooks.test.js +6 -3
- package/harness/packages/core/test/hosts.test.js +50 -0
- package/harness/packages/core/test/plugins.test.js +5 -3
- package/harness/packages/core/test/tools.test.js +98 -2
- package/harness/packages/server/public/app.js +343 -6729
- package/harness/packages/server/public/chat.js +1893 -0
- package/harness/packages/server/public/chrome.js +812 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/index.html +19 -19
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1076 -0
- package/harness/packages/server/public/styles.css +29 -2
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram.js +1 -0
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/procs.js +30 -3
- package/harness/packages/server/src/lib/scheduler.js +11 -2
- package/harness/packages/server/src/lib/tasks.js +20 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +15 -4
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/procs.js +2 -6
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +28 -2
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/helpers.js +107 -0
- package/harness/packages/server/test/ops.test.js +511 -0
- package/harness/packages/server/test/procs-manager.test.js +43 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +465 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +1 -1
- package/harness/packages/server/test/server.test.js +0 -2878
|
@@ -0,0 +1,1893 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addTokens,
|
|
3
|
+
isMobile,
|
|
4
|
+
modeSelect,
|
|
5
|
+
modelSelect,
|
|
6
|
+
openFolderPicker,
|
|
7
|
+
providerSelect,
|
|
8
|
+
setContextUsage,
|
|
9
|
+
setCwd,
|
|
10
|
+
setSidebarOpen,
|
|
11
|
+
updateStatus,
|
|
12
|
+
yesInput,
|
|
13
|
+
} from './chrome.js';
|
|
14
|
+
import { hideSlashMenu, notify, onChatInputChanged, runCommand, toast } from './commands.js';
|
|
15
|
+
import { $, api, button, clear, el, icon, openStream, showConfirm, state, storage, streamPost } from './core.js';
|
|
16
|
+
import { isLocalHost, loadPreview, localPortOf, openRemoteTunnel, setExplorerMode } from './explorer.js';
|
|
17
|
+
import { renderMarkdown } from './markdown.js';
|
|
18
|
+
import { summarizeArgs } from './reconcile.js';
|
|
19
|
+
import { followRun } from './runStream.js';
|
|
20
|
+
import {
|
|
21
|
+
closeTab,
|
|
22
|
+
openTab,
|
|
23
|
+
setWorking,
|
|
24
|
+
showStandalonePermission,
|
|
25
|
+
syncInterruptBtn,
|
|
26
|
+
whenIdle,
|
|
27
|
+
} from './sessions.js';
|
|
28
|
+
import { setStatus } from './settings.js';
|
|
29
|
+
import { reconcile, renderAlertSignals } from './system.js';
|
|
30
|
+
import { narrar, reproducirVoz } from './voice.js';
|
|
31
|
+
|
|
32
|
+
// `scrollNavFrame` debe existir antes de `initLayout()`: `applyPanelWidths`
|
|
33
|
+
// llama a `scheduleScrollNav()` durante el arranque (evita el TDZ).
|
|
34
|
+
export let scrollNavFrame = 0;
|
|
35
|
+
|
|
36
|
+
/* --- chat ---------------------------------------------------------------- */
|
|
37
|
+
|
|
38
|
+
export const blankPane = $('#messages');
|
|
39
|
+
export const panesEl = $('#panes');
|
|
40
|
+
export const tabbarEl = $('#tabbar');
|
|
41
|
+
export const chatForm = $('#chat-form');
|
|
42
|
+
export const chatInput = $('#chat-input');
|
|
43
|
+
export const submitBtn = chatForm.querySelector('button[type="submit"]');
|
|
44
|
+
export const stopBtn = $('#stop-btn');
|
|
45
|
+
export const interruptBtn = $('#interrupt-btn');
|
|
46
|
+
export const controllers = new Map();
|
|
47
|
+
// `whenIdle` espera a que el turno de una sesion termine (lo usa "interrumpir").
|
|
48
|
+
export const idleWaiters = new Map();
|
|
49
|
+
// Sesiones donde el turno actual se esta cortando a proposito: `drainQueue` no
|
|
50
|
+
// debe arrancar un mensaje al final de ese turno (lo arranca el interruptor).
|
|
51
|
+
export const interrupting = new Set();
|
|
52
|
+
export const sessionsEl = $('#sessions');
|
|
53
|
+
|
|
54
|
+
export let activePane = blankPane;
|
|
55
|
+
|
|
56
|
+
export const paneOf = (message) => message?.bubble?.closest('.messages') ?? activePane;
|
|
57
|
+
|
|
58
|
+
export const scrollMessages = (pane = activePane) => {
|
|
59
|
+
if (pane) pane.scrollTop = pane.scrollHeight;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const scrollNav = $('#scroll-nav');
|
|
63
|
+
export const scrollTopBtn = $('#scroll-top');
|
|
64
|
+
export const scrollDownBtn = $('#scroll-down');
|
|
65
|
+
|
|
66
|
+
/** Muestra/oculta los saltos de scroll según el pane activo. */
|
|
67
|
+
export function updateScrollNav() {
|
|
68
|
+
const pane = activePane;
|
|
69
|
+
if (!pane) return;
|
|
70
|
+
const max = pane.scrollHeight - pane.clientHeight;
|
|
71
|
+
const visible = max > 40;
|
|
72
|
+
scrollNav.hidden = !visible;
|
|
73
|
+
if (!visible) return;
|
|
74
|
+
scrollTopBtn.disabled = pane.scrollTop < 40;
|
|
75
|
+
scrollDownBtn.disabled = max - pane.scrollTop < 40;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function scheduleScrollNav() {
|
|
79
|
+
if (scrollNavFrame) return;
|
|
80
|
+
scrollNavFrame = requestAnimationFrame(() => {
|
|
81
|
+
scrollNavFrame = 0;
|
|
82
|
+
updateScrollNav();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function addMessage(role, text = '', pane = activePane) {
|
|
87
|
+
pane.querySelector('.empty')?.remove();
|
|
88
|
+
const wrap = el('div', `msg ${role}`);
|
|
89
|
+
const label = el('div', 'msg-label');
|
|
90
|
+
let modeEl = null;
|
|
91
|
+
if (role === 'user') {
|
|
92
|
+
label.textContent = '❯ vos';
|
|
93
|
+
} else {
|
|
94
|
+
label.appendChild(el('span', 'msg-name', 'tamnora'));
|
|
95
|
+
modeEl = el('span', 'msg-mode');
|
|
96
|
+
modeEl.hidden = true;
|
|
97
|
+
label.appendChild(modeEl);
|
|
98
|
+
}
|
|
99
|
+
const bubble = el('div', 'bubble');
|
|
100
|
+
const textEl = el('div', 'text');
|
|
101
|
+
const tools = el('div', 'tools');
|
|
102
|
+
bubble.append(textEl, tools);
|
|
103
|
+
wrap.append(label, bubble);
|
|
104
|
+
pane.appendChild(wrap);
|
|
105
|
+
const message = { role, wrap, bubble, label, modeEl, textEl, tools, reasoningEl: null, id: null };
|
|
106
|
+
addMessageActions(message);
|
|
107
|
+
if (text) setText(message, text);
|
|
108
|
+
scrollMessages(pane);
|
|
109
|
+
return message;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function setMessageMode(message, modeId) {
|
|
113
|
+
if (!message?.modeEl) return;
|
|
114
|
+
const mode = modeId === 'plan' ? 'plan' : 'build';
|
|
115
|
+
message.modeEl.textContent = mode === 'plan' ? 'Plan' : 'Build';
|
|
116
|
+
message.modeEl.dataset.mode = mode;
|
|
117
|
+
message.modeEl.hidden = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export let openMessageMenu = null;
|
|
121
|
+
|
|
122
|
+
/** El menu flota (position: fixed) para no quedar recortado por el scroll. */
|
|
123
|
+
export function positionMessageMenu() {
|
|
124
|
+
if (!openMessageMenu) return;
|
|
125
|
+
const { menu, trigger } = openMessageMenu;
|
|
126
|
+
const rect = trigger.getBoundingClientRect();
|
|
127
|
+
const size = menu.getBoundingClientRect();
|
|
128
|
+
let top = rect.bottom + 4;
|
|
129
|
+
if (top + size.height > window.innerHeight - 8) top = Math.max(8, rect.top - size.height - 4);
|
|
130
|
+
let left = rect.left;
|
|
131
|
+
if (left + size.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - size.width - 8);
|
|
132
|
+
menu.style.top = `${top}px`;
|
|
133
|
+
menu.style.left = `${left}px`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function closeMessageMenus() {
|
|
137
|
+
if (!openMessageMenu) return;
|
|
138
|
+
const current = openMessageMenu;
|
|
139
|
+
openMessageMenu = null;
|
|
140
|
+
current.menu.hidden = true;
|
|
141
|
+
current.trigger.setAttribute('aria-expanded', 'false');
|
|
142
|
+
current.actions?.appendChild(current.menu);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function toggleMessageMenu(message) {
|
|
146
|
+
const wasOpen = openMessageMenu === message;
|
|
147
|
+
closeMessageMenus();
|
|
148
|
+
if (wasOpen) return;
|
|
149
|
+
document.body.appendChild(message.menu);
|
|
150
|
+
message.menu.hidden = false;
|
|
151
|
+
message.trigger.setAttribute('aria-expanded', 'true');
|
|
152
|
+
openMessageMenu = message;
|
|
153
|
+
positionMessageMenu();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function addMessageActions(message) {
|
|
157
|
+
const actions = el('div', 'msg-actions');
|
|
158
|
+
const trigger = el('button', 'msg-menu-btn');
|
|
159
|
+
trigger.type = 'button';
|
|
160
|
+
trigger.appendChild(icon(window.matchMedia('(min-width: 821px)').matches ? 'dots-horizontal' : 'dots-vertical', 16));
|
|
161
|
+
trigger.title = 'opciones del mensaje';
|
|
162
|
+
trigger.setAttribute('aria-haspopup', 'menu');
|
|
163
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
164
|
+
trigger.setAttribute('aria-label', 'opciones del mensaje');
|
|
165
|
+
trigger.addEventListener('click', () => toggleMessageMenu(message));
|
|
166
|
+
|
|
167
|
+
const menu = el('div', 'msg-menu');
|
|
168
|
+
menu.hidden = true;
|
|
169
|
+
menu.setAttribute('role', 'menu');
|
|
170
|
+
const addItem = (label, run, iconName, cls = 'msg-act') => {
|
|
171
|
+
const item = el('button', cls);
|
|
172
|
+
item.type = 'button';
|
|
173
|
+
item.setAttribute('role', 'menuitem');
|
|
174
|
+
if (iconName) item.appendChild(icon(iconName, 15, 'mi'));
|
|
175
|
+
item.appendChild(el('span', 'mlabel', label));
|
|
176
|
+
item.addEventListener('click', () => {
|
|
177
|
+
closeMessageMenus();
|
|
178
|
+
run();
|
|
179
|
+
});
|
|
180
|
+
menu.appendChild(item);
|
|
181
|
+
};
|
|
182
|
+
addItem('copiar', () => copyMessage(message), 'copy');
|
|
183
|
+
if (message.role === 'assistant') addItem('regenerar', () => regenerate(message), 'refresh-cw');
|
|
184
|
+
if (message.role === 'assistant') addItem('deshacer cambios', () => undoTurn(message), 'undo');
|
|
185
|
+
if (message.role === 'user') addItem('editar', () => editMessage(message), 'pencil');
|
|
186
|
+
menu.appendChild(el('div', 'msg-menu-sep'));
|
|
187
|
+
addItem('borrar desde acá', () => deleteFrom(message), 'trash', 'msg-act danger');
|
|
188
|
+
|
|
189
|
+
actions.append(trigger, menu);
|
|
190
|
+
message.bubble.appendChild(actions);
|
|
191
|
+
message.actions = actions;
|
|
192
|
+
message.menu = menu;
|
|
193
|
+
message.trigger = trigger;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function messageId(message) {
|
|
197
|
+
return message.wrap?.dataset.messageId || message.id || null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function copyMessage(message) {
|
|
201
|
+
const text = message.role === 'assistant' ? message.textEl.innerText : message.textEl.textContent;
|
|
202
|
+
const write = navigator.clipboard?.writeText(text);
|
|
203
|
+
if (write?.catch) write.catch(() => {});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function renderPane(id) {
|
|
207
|
+
const record = state.panes.get(id);
|
|
208
|
+
if (!record) return null;
|
|
209
|
+
const detail = await api(`/api/sessions/${id}`).catch(() => null);
|
|
210
|
+
if (!detail) return null;
|
|
211
|
+
record.el.replaceChildren();
|
|
212
|
+
renderStoredMessages(detail.messages, record.el);
|
|
213
|
+
if (!detail.messages.length) record.el.appendChild(el('div', 'empty', 'Sesión vacía.'));
|
|
214
|
+
// Los permisos pendientes no son mensajes, asi que `replaceChildren` los
|
|
215
|
+
// borra. Hay que repintarlos aca: sin esto una tarjeta de permiso se ve y
|
|
216
|
+
// al primer re-render (run_start, sweep, reconexion) desaparece para siempre.
|
|
217
|
+
for (const ask of detail.permissions ?? []) showStandalonePermission(record, ask);
|
|
218
|
+
for (const ask of detail.questions ?? []) showStandaloneQuestion(record, ask);
|
|
219
|
+
return detail;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function reloadPane(id) {
|
|
223
|
+
const detail = await renderPane(id);
|
|
224
|
+
const record = state.panes.get(id);
|
|
225
|
+
if (detail?.running && record && !record.working) await attachRun(id, record.el);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function regenerate(message) {
|
|
229
|
+
const id = message?.wrap?.closest('.messages')?.dataset.session || state.sessionId;
|
|
230
|
+
if (!id || state.panes.get(id)?.working) return;
|
|
231
|
+
const detail = await api(`/api/sessions/${id}`).catch(() => null);
|
|
232
|
+
if (!detail) return;
|
|
233
|
+
const messages = detail.messages || [];
|
|
234
|
+
let lastUser = null;
|
|
235
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
236
|
+
if (messages[i].role === 'user') {
|
|
237
|
+
lastUser = messages[i];
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!lastUser) return;
|
|
242
|
+
await api(`/api/sessions/${id}/truncate`, { method: 'POST', body: { fromId: lastUser.id } }).catch(() => {});
|
|
243
|
+
await reloadPane(id);
|
|
244
|
+
chatInput.value = userText(lastUser.content);
|
|
245
|
+
await sendMessage();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function undoTurn(message) {
|
|
249
|
+
const id = message?.wrap?.closest('.messages')?.dataset.session || state.sessionId;
|
|
250
|
+
if (!id || state.panes.get(id)?.working) return;
|
|
251
|
+
const detail = await api(`/api/sessions/${id}`).catch(() => null);
|
|
252
|
+
if (!detail) return;
|
|
253
|
+
const messages = detail.messages || [];
|
|
254
|
+
const at = messages.findIndex((m) => m.id === messageId(message));
|
|
255
|
+
let turn = null;
|
|
256
|
+
for (let i = at >= 0 ? at : messages.length - 1; i >= 0; i--) {
|
|
257
|
+
if (messages[i].role === 'user') {
|
|
258
|
+
turn = messages[i];
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!turn) return;
|
|
263
|
+
if (
|
|
264
|
+
!(await showConfirm('¿Deshacer este turno? Se restauran los archivos a como estaban y se borra el turno del historial.', {
|
|
265
|
+
title: 'Deshacer cambios',
|
|
266
|
+
confirmLabel: 'deshacer',
|
|
267
|
+
danger: true,
|
|
268
|
+
}))
|
|
269
|
+
) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
await api(`/api/sessions/${id}/rewind`, { method: 'POST', body: { turn: turn.id } }).catch(() => {});
|
|
273
|
+
await reloadPane(id);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function editMessage(message) { const id = messageId(message);
|
|
277
|
+
chatInput.value = message.textEl.textContent;
|
|
278
|
+
chatInput.focus();
|
|
279
|
+
state.pendingEdit = id ? { fromId: id } : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function deleteFrom(message) {
|
|
283
|
+
const id = messageId(message);
|
|
284
|
+
if (!id || !(await showConfirm('¿Borrar este mensaje y todo lo posterior?', { title: 'Borrar mensajes', confirmLabel: 'borrar', danger: true }))) return;
|
|
285
|
+
const sessionKey = state.sessionId;
|
|
286
|
+
await api(`/api/sessions/${sessionKey}/truncate`, { method: 'POST', body: { fromId: id } }).catch(() => {});
|
|
287
|
+
await reloadPane(sessionKey);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const ACTIVITY = {
|
|
291
|
+
thinking: { cls: 'loader-thinking', label: 'pensando…' },
|
|
292
|
+
working: { cls: 'loader-working', label: 'trabajando…' },
|
|
293
|
+
listening: { cls: 'loader-working', label: 'escuchando…' },
|
|
294
|
+
reconnecting: { cls: 'loader-working', label: 'reconectando…' },
|
|
295
|
+
stopping: { cls: 'loader-working', label: 'deteniendo…' },
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export const footerActivityEl = $('#sb-activity');
|
|
299
|
+
|
|
300
|
+
/** El indicador de "pensando/trabajando" vive en el footer (una sola vez). */
|
|
301
|
+
export function renderFooterActivity(kind) {
|
|
302
|
+
if (!footerActivityEl) return;
|
|
303
|
+
if (!kind) {
|
|
304
|
+
footerActivityEl.hidden = true;
|
|
305
|
+
footerActivityEl.replaceChildren();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const def = ACTIVITY[kind] ?? ACTIVITY.thinking;
|
|
309
|
+
footerActivityEl.hidden = false;
|
|
310
|
+
footerActivityEl.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function setActivity(message, kind) {
|
|
314
|
+
// Solo el turno de la sesion activa refleja su actividad en el footer.
|
|
315
|
+
if (paneOf(message) !== activePane) return;
|
|
316
|
+
renderFooterActivity(kind);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function setText(message, text) {
|
|
320
|
+
const value = String(text ?? '');
|
|
321
|
+
if (message.role === 'assistant') message.textEl.innerHTML = renderMarkdown(value);
|
|
322
|
+
else message.textEl.textContent = value;
|
|
323
|
+
scrollMessages(paneOf(message));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function appendImages(message, urls) {
|
|
327
|
+
if (!urls.length) return;
|
|
328
|
+
const wrap = el('div', 'msg-images');
|
|
329
|
+
for (const url of urls) {
|
|
330
|
+
const img = el('img', 'msg-image');
|
|
331
|
+
img.src = url;
|
|
332
|
+
img.alt = 'imagen adjunta';
|
|
333
|
+
wrap.appendChild(img);
|
|
334
|
+
}
|
|
335
|
+
message.bubble.insertBefore(wrap, message.tools);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function ensureReasoning(message) {
|
|
339
|
+
if (message.reasoningEl) return message.reasoningEl;
|
|
340
|
+
const details = el('details', 'reasoning');
|
|
341
|
+
details.appendChild(el('summary', '', 'razonamiento'));
|
|
342
|
+
const body = el('div', 'body');
|
|
343
|
+
details.appendChild(body);
|
|
344
|
+
message.bubble.insertBefore(details, message.tools);
|
|
345
|
+
message.reasoningEl = body;
|
|
346
|
+
return body;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function renderToolOut(node, text) {
|
|
350
|
+
const content = String(text ?? '');
|
|
351
|
+
const lines = content.split('\n');
|
|
352
|
+
const adds = lines.filter((line) => line.startsWith('+ ')).length;
|
|
353
|
+
const dels = lines.filter((line) => line.startsWith('- ')).length;
|
|
354
|
+
if (adds === 0 || dels === 0) {
|
|
355
|
+
node.textContent = content;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
node.textContent = '';
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
const cls = line.startsWith('+ ')
|
|
361
|
+
? 'diff-add'
|
|
362
|
+
: line.startsWith('- ')
|
|
363
|
+
? 'diff-del'
|
|
364
|
+
: /^ {2}…/.test(line)
|
|
365
|
+
? 'diff-sep'
|
|
366
|
+
: 'diff-eq';
|
|
367
|
+
node.appendChild(el('span', `diff-line ${cls}`, line));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export const READ_ONLY_TOOLS = new Set([
|
|
372
|
+
'read',
|
|
373
|
+
'list',
|
|
374
|
+
'glob',
|
|
375
|
+
'grep',
|
|
376
|
+
'fetch_url',
|
|
377
|
+
'git_status',
|
|
378
|
+
'git_diff',
|
|
379
|
+
'git_log',
|
|
380
|
+
'task',
|
|
381
|
+
'spawn_agent',
|
|
382
|
+
]);
|
|
383
|
+
export const toolCardIndex = new Map();
|
|
384
|
+
|
|
385
|
+
export function isReadOnlyTool(name) {
|
|
386
|
+
const value = String(name ?? '');
|
|
387
|
+
if (READ_ONLY_TOOLS.has(value)) return true;
|
|
388
|
+
return /^mcp__.+__(read|list|search|get|fetch|query)/i.test(value);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function shortText(value, max = 80) {
|
|
392
|
+
const raw = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
393
|
+
return raw.length > max ? `${raw.slice(0, max - 1)}…` : raw;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function toolSummary(name, args) {
|
|
397
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
398
|
+
switch (name) {
|
|
399
|
+
case 'read': {
|
|
400
|
+
const base = a.path ? shortText(a.path, 60) : '.';
|
|
401
|
+
const offset = Number(a.offset) || 0;
|
|
402
|
+
const limit = Number(a.limit) || 0;
|
|
403
|
+
if (offset && limit) return `${base}:${offset}-${offset + limit - 1}`;
|
|
404
|
+
if (offset) return `${base}:${offset}+`;
|
|
405
|
+
return base;
|
|
406
|
+
}
|
|
407
|
+
case 'write':
|
|
408
|
+
case 'edit':
|
|
409
|
+
return a.path ? shortText(a.path, 60) : 'archivo';
|
|
410
|
+
case 'list':
|
|
411
|
+
return a.path ? shortText(a.path, 60) : '.';
|
|
412
|
+
case 'glob':
|
|
413
|
+
return [a.pattern ? `"${shortText(a.pattern, 50)}"` : '', a.path ? `en ${shortText(a.path, 40)}` : '']
|
|
414
|
+
.filter(Boolean)
|
|
415
|
+
.join(' ');
|
|
416
|
+
case 'grep':
|
|
417
|
+
return [
|
|
418
|
+
a.pattern ? `"${shortText(a.pattern, 50)}"` : '',
|
|
419
|
+
a.include ? shortText(a.include, 30) : '',
|
|
420
|
+
a.path ? `en ${shortText(a.path, 40)}` : '',
|
|
421
|
+
]
|
|
422
|
+
.filter(Boolean)
|
|
423
|
+
.join(' ');
|
|
424
|
+
case 'bash':
|
|
425
|
+
return shortText(String(a.command ?? '').split('\n')[0], 90);
|
|
426
|
+
case 'fetch_url':
|
|
427
|
+
return shortText(a.url, 90);
|
|
428
|
+
case 'speak':
|
|
429
|
+
return shortText(a.texto, 90);
|
|
430
|
+
case 'screenshot':
|
|
431
|
+
case 'console_errors':
|
|
432
|
+
case 'preview_url':
|
|
433
|
+
return shortText(a.url, 90);
|
|
434
|
+
case 'git_diff':
|
|
435
|
+
return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
|
|
436
|
+
.filter(Boolean)
|
|
437
|
+
.join(' ');
|
|
438
|
+
case 'git_log':
|
|
439
|
+
return a.limit ? `${a.limit} commits` : '';
|
|
440
|
+
case 'git_commit':
|
|
441
|
+
return shortText(a.message, 70);
|
|
442
|
+
case 'task':
|
|
443
|
+
case 'spawn_agent':
|
|
444
|
+
return shortText(a.description || a.prompt, 80);
|
|
445
|
+
default:
|
|
446
|
+
return '';
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function parseToolArgs(call) {
|
|
451
|
+
const raw = call?.function?.arguments ?? call?.arguments;
|
|
452
|
+
if (raw && typeof raw === 'object') return raw;
|
|
453
|
+
if (typeof raw !== 'string') return {};
|
|
454
|
+
try {
|
|
455
|
+
return JSON.parse(raw);
|
|
456
|
+
} catch {
|
|
457
|
+
return {};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function isFailedToolText(text) {
|
|
462
|
+
return /^Error|^Permiso denegado/i.test(String(text ?? ''));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Nombre de archivo de una captura, del meta o del texto del resultado. */
|
|
466
|
+
export function screenshotName(data, text) {
|
|
467
|
+
const fromMeta = data?.meta?.screenshot?.name;
|
|
468
|
+
if (fromMeta) return String(fromMeta);
|
|
469
|
+
const match = /capturas\/([a-z0-9][a-z0-9-]*\.png)/i.exec(String(text ?? ''));
|
|
470
|
+
return match ? match[1] : null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Muestra la captura del tool `screenshot` en su tarjeta (clic para ampliar). */
|
|
474
|
+
export function showScreenshot(entry, name) {
|
|
475
|
+
if (!entry || !name || entry.shot) return;
|
|
476
|
+
entry.shot = true;
|
|
477
|
+
const img = el('img', 'tool-shot');
|
|
478
|
+
img.loading = 'lazy';
|
|
479
|
+
img.alt = 'captura de pantalla';
|
|
480
|
+
img.src = `/api/screenshots/${encodeURIComponent(name)}`;
|
|
481
|
+
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
|
482
|
+
entry.card.appendChild(img);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Cierra la tarjeta de un tool-call (evento tool_result o mensaje persistido). */
|
|
486
|
+
export function finishToolCard(entry, data) {
|
|
487
|
+
if (!entry) return;
|
|
488
|
+
const preview = data?.preview ?? data?.result ?? '';
|
|
489
|
+
const failed = data?.failed ?? isFailedToolText(preview);
|
|
490
|
+
renderToolOut(entry.out, preview);
|
|
491
|
+
entry.card.classList.remove('running');
|
|
492
|
+
entry.card.classList.toggle('failed', failed);
|
|
493
|
+
if (!failed) entry.card.classList.add('done');
|
|
494
|
+
entry.glyph.textContent = failed ? '✗' : '✓';
|
|
495
|
+
if (data?.truncated) {
|
|
496
|
+
entry.truncated = true;
|
|
497
|
+
entry.more.hidden = false;
|
|
498
|
+
}
|
|
499
|
+
if (!failed) showScreenshot(entry, screenshotName(data, preview));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function previewText(value, maxLines = 24, maxChars = 4000) {
|
|
503
|
+
const content = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2) ?? '';
|
|
504
|
+
const lines = content.split('\n');
|
|
505
|
+
let preview = lines.slice(0, maxLines).join('\n');
|
|
506
|
+
let truncated = lines.length > maxLines;
|
|
507
|
+
if (preview.length > maxChars) {
|
|
508
|
+
preview = preview.slice(0, maxChars);
|
|
509
|
+
truncated = true;
|
|
510
|
+
}
|
|
511
|
+
return { preview, truncated };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function ensureToolsToggle(message) {
|
|
515
|
+
if (!message.toolsToggle) {
|
|
516
|
+
const bar = el('div', 'tools-actions');
|
|
517
|
+
const label = el('span', 'tools-count');
|
|
518
|
+
const toggle = button('colapsar todo', () => {
|
|
519
|
+
const details = [...message.tools.querySelectorAll('details.tool-card')];
|
|
520
|
+
const anyOpen = details.some((node) => node.open);
|
|
521
|
+
for (const node of details) node.open = !anyOpen;
|
|
522
|
+
toggle.textContent = anyOpen ? 'expandir todo' : 'colapsar todo';
|
|
523
|
+
}, 'tools-toggle');
|
|
524
|
+
bar.append(label, toggle);
|
|
525
|
+
message.tools.prepend(bar);
|
|
526
|
+
message.toolsToggle = bar;
|
|
527
|
+
message.toolsCount = label;
|
|
528
|
+
message.toolsToggleBtn = toggle;
|
|
529
|
+
}
|
|
530
|
+
const total = message.tools.querySelectorAll('details.tool-card').length;
|
|
531
|
+
const groups = new Set([...message.tools.querySelectorAll('details.tool-card')].map((n) => n.dataset.round ?? ''));
|
|
532
|
+
message.toolsToggle.hidden = total < 2;
|
|
533
|
+
message.toolsCount.textContent = groups.size > 1 ? `${total} pasos` : `${total} herramientas`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export async function loadFullToolResult(entry, message) {
|
|
537
|
+
if (entry.full || !entry.callId) return;
|
|
538
|
+
const sessionId = paneOf(message)?.dataset.session;
|
|
539
|
+
if (!sessionId) return;
|
|
540
|
+
entry.more.disabled = true;
|
|
541
|
+
entry.more.textContent = 'cargando…';
|
|
542
|
+
const data = await api(
|
|
543
|
+
`/api/sessions/${sessionId}/tool-result?callId=${encodeURIComponent(entry.callId)}`
|
|
544
|
+
).catch(() => null);
|
|
545
|
+
entry.more.disabled = false;
|
|
546
|
+
if (data?.content != null) {
|
|
547
|
+
renderToolOut(entry.out, data.content);
|
|
548
|
+
entry.full = true;
|
|
549
|
+
entry.more.hidden = true;
|
|
550
|
+
} else {
|
|
551
|
+
entry.more.textContent = 'ver completo';
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Botón del tool `speak`: encola el audio pedido en el reproductor global. */
|
|
556
|
+
export function speakControl(args) {
|
|
557
|
+
const texto = String(args?.texto ?? '').trim();
|
|
558
|
+
if (!texto) return null;
|
|
559
|
+
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, pitch: args?.pitch, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
560
|
+
btn.append(icon('play', 13), el('span', '', 'escuchar'));
|
|
561
|
+
return btn;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Texto de la tarjeta: el server ya manda `summary` calculado; esto es el fallback. */
|
|
565
|
+
export function addToolCard(message, name, args, { callId = null, open = null, round = null, summary = null } = {}) {
|
|
566
|
+
const collapsed = open === null ? isReadOnlyTool(name) : !open;
|
|
567
|
+
const card = el('details', 'tool-card running');
|
|
568
|
+
card.open = !collapsed;
|
|
569
|
+
if (round != null) card.dataset.round = String(round);
|
|
570
|
+
const head = el('summary', 'tool-head');
|
|
571
|
+
const glyph = el('span', 'tool-glyph', '⏺');
|
|
572
|
+
const argsEl = el('span', 'tool-args', summary ?? toolSummary(name, args));
|
|
573
|
+
head.append(glyph, el('span', 'tool-name', name), argsEl);
|
|
574
|
+
const out = el('div', 'tool-out');
|
|
575
|
+
const speak = name === 'speak' ? speakControl(args) : null;
|
|
576
|
+
const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
|
|
577
|
+
more.hidden = true;
|
|
578
|
+
card.append(head, out);
|
|
579
|
+
if (speak) card.appendChild(speak);
|
|
580
|
+
card.appendChild(more);
|
|
581
|
+
message.tools.appendChild(card);
|
|
582
|
+
ensureToolsToggle(message);
|
|
583
|
+
scrollMessages(paneOf(message));
|
|
584
|
+
|
|
585
|
+
const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
|
|
586
|
+
card.addEventListener('toggle', () => {
|
|
587
|
+
if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
|
|
588
|
+
});
|
|
589
|
+
return entry;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* La tarjeta de un permiso que sigue en el DOM, o null si ya no esta (la
|
|
594
|
+
* borraron, p. ej. un re-render del pane). Evita dibujar dos tarjetas por el
|
|
595
|
+
* mismo permiso sin dejar entradas huerfanas.
|
|
596
|
+
*/
|
|
597
|
+
export function livePermission(askId) {
|
|
598
|
+
const entry = state.permissionCards.get(askId);
|
|
599
|
+
return entry && entry.card.isConnected ? entry : null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function addPermissionCard(message, data) {
|
|
603
|
+
// Llega por el stream de la sesion y por el canal global: sin esta guarda se
|
|
604
|
+
// dibujan dos tarjetas por el mismo permiso. Pero `renderPane` borra el pane,
|
|
605
|
+
// asi que si la tarjeta vieja quedo desconectada hay que rehacerla en vez de
|
|
606
|
+
// dejar el mapa apuntando a algo que ya no esta en el DOM.
|
|
607
|
+
const previous = state.permissionCards.get(data.askId);
|
|
608
|
+
if (previous && previous.card.isConnected) return previous.card;
|
|
609
|
+
const card = el('div', 'perm-card');
|
|
610
|
+
const head = el('div', 'perm-head');
|
|
611
|
+
head.append(
|
|
612
|
+
el('span', 'perm-icon', '⚠'),
|
|
613
|
+
el('span', 'perm-title', `permiso para ${data.name}${repeatedToolNotice(data.name)}`)
|
|
614
|
+
);
|
|
615
|
+
const args = el('div', 'perm-args', summarizeArgs(data.name, data.args, 300));
|
|
616
|
+
const actions = el('div', 'perm-actions');
|
|
617
|
+
const verdict = el('span', 'muted', 'requiere aprobación');
|
|
618
|
+
const buttons = {
|
|
619
|
+
deny: button('denegar', () => decide(false, 'once'), 'btn'),
|
|
620
|
+
once: button('permitir una vez', () => decide(true, 'once'), 'btn primary'),
|
|
621
|
+
always: button('autorizar todo', () => decide(true, 'session'), 'btn'),
|
|
622
|
+
};
|
|
623
|
+
actions.append(verdict, el('span', 'spacer'), buttons.deny, buttons.once, buttons.always);
|
|
624
|
+
card.append(head, args, actions);
|
|
625
|
+
// Una vez resuelta queda minima, pero se puede reabrir para leer que se pidio.
|
|
626
|
+
// Sin esto la tarjeta es info muerta: nunca se puede volver a desplegar.
|
|
627
|
+
head.addEventListener('click', () => {
|
|
628
|
+
if (!card.classList.contains('approved') && !card.classList.contains('denied')) return;
|
|
629
|
+
card.classList.toggle('collapsed');
|
|
630
|
+
});
|
|
631
|
+
message.tools.appendChild(card);
|
|
632
|
+
scrollMessages(paneOf(message));
|
|
633
|
+
state.permissionCards.set(data.askId, { card, verdict, buttons, message });
|
|
634
|
+
|
|
635
|
+
async function decide(allow, scope) {
|
|
636
|
+
for (const btn of Object.values(buttons)) btn.disabled = true;
|
|
637
|
+
card.classList.remove('pending');
|
|
638
|
+
card.classList.add('collapsed', allow ? 'approved' : 'denied');
|
|
639
|
+
card.querySelector('.perm-icon').textContent = allow ? '✓' : '✗';
|
|
640
|
+
verdict.textContent =
|
|
641
|
+
allow && scope === 'session' ? 'autorizado para toda la sesión' : allow ? 'aprobado' : 'denegado';
|
|
642
|
+
if (allow && scope === 'session') yesInput.checked = true;
|
|
643
|
+
try {
|
|
644
|
+
await api(`/api/permissions/${encodeURIComponent(data.askId)}`, {
|
|
645
|
+
method: 'POST',
|
|
646
|
+
body: { allow, scope },
|
|
647
|
+
});
|
|
648
|
+
} catch (error) {
|
|
649
|
+
// Esto se tragaba el error. La UI se cerraba igual, se borraba el permiso
|
|
650
|
+
// local y el siguiente reconcile lo volvia a traer porque el server
|
|
651
|
+
// seguia tenerlo pendiente: el permiso reaparecia una y otra vez sin
|
|
652
|
+
// decir por que.
|
|
653
|
+
if (error?.status === 404) {
|
|
654
|
+
// El server ya no tiene ese permiso (expiro o lo resolvio otra
|
|
655
|
+
// ventana): la tarjeta era vieja, no hay nada que decidir.
|
|
656
|
+
card.classList.remove('pending');
|
|
657
|
+
card.classList.add('collapsed', 'denied');
|
|
658
|
+
verdict.textContent = 'ya no aplica';
|
|
659
|
+
state.pendingAsks.delete(data.askId);
|
|
660
|
+
toast('Ese permiso ya no estaba esperando decisión.');
|
|
661
|
+
reconcile({ immediate: true });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
for (const btn of Object.values(buttons)) btn.disabled = false;
|
|
665
|
+
card.classList.remove('approved', 'denied');
|
|
666
|
+
card.classList.add('pending');
|
|
667
|
+
card.querySelector('.perm-icon').textContent = '⚠';
|
|
668
|
+
verdict.textContent = 'requiere aprobación';
|
|
669
|
+
toast(`No se pudo resolver el permiso: ${error.message}`);
|
|
670
|
+
reconcile({ immediate: true });
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
rememberDecision(data.name);
|
|
674
|
+
// El permiso dejo de existir en el server: que el proximo snapshot no lo
|
|
675
|
+
// vuelva a marcar como pendiente ni lo vuelva a pintar.
|
|
676
|
+
state.pendingAsks.delete(data.askId);
|
|
677
|
+
reconcile({ immediate: true });
|
|
678
|
+
}
|
|
679
|
+
card.classList.add('pending');
|
|
680
|
+
return card;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function rememberDecision(name) {
|
|
684
|
+
state.recentDecisions.set(name, Date.now());
|
|
685
|
+
// No crecer sin limite: solo interesa "lo acabo de aprobar".
|
|
686
|
+
for (const [tool, at] of state.recentDecisions) {
|
|
687
|
+
if (Date.now() - at > 120000) state.recentDecisions.delete(tool);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Si la misma tool vuelve a pedir permiso segundos despues de que la
|
|
693
|
+
* aprobaste, es una peticion **nueva** (otro `askId`), no la misma que vuelve:
|
|
694
|
+
* la tarjeta lo dice, asi que el agente reintentando no parece un bug.
|
|
695
|
+
*/
|
|
696
|
+
export function repeatedToolNotice(name) {
|
|
697
|
+
const at = state.recentDecisions.get(name);
|
|
698
|
+
if (!at) return '';
|
|
699
|
+
const seconds = Math.round((Date.now() - at) / 1000);
|
|
700
|
+
return seconds < 120 ? ` · lo pidió de nuevo (aprobaste hace ${seconds} s)` : '';
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Resuelve una tarjeta desde aca (banner o `permission_resolved` de otro). */
|
|
704
|
+
export function resolvePermissionCard(entry, allow) {
|
|
705
|
+
if (!entry) return;
|
|
706
|
+
for (const btn of Object.values(entry.buttons)) btn.disabled = true;
|
|
707
|
+
entry.card.classList.remove('pending');
|
|
708
|
+
if (allow === null) return;
|
|
709
|
+
entry.card.classList.add('collapsed', allow ? 'approved' : 'denied');
|
|
710
|
+
entry.card.querySelector('.perm-icon').textContent = allow ? '✓' : '✗';
|
|
711
|
+
entry.verdict.textContent = allow ? 'aprobado desde otra ventana' : 'denegado desde otra ventana';
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/* --- formularios interactivos (ask_user) ---------------------------------- */
|
|
715
|
+
|
|
716
|
+
export function liveQuestion(askId) {
|
|
717
|
+
const entry = state.questionCards.get(askId);
|
|
718
|
+
return entry && entry.card.isConnected ? entry : null;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Formulario que el modelo pide con `ask_user`: en vez de preguntas numeradas en
|
|
723
|
+
* el texto, la consola renderiza los campos y el usuario los contesta de una.
|
|
724
|
+
*/
|
|
725
|
+
export function addQuestionCard(container, data) {
|
|
726
|
+
const previous = state.questionCards.get(data.askId);
|
|
727
|
+
if (previous && previous.card.isConnected) return previous.card;
|
|
728
|
+
const card = el('div', 'question-card');
|
|
729
|
+
if (data.title) card.appendChild(el('div', 'question-title', data.title));
|
|
730
|
+
const form = el('form', 'question-form');
|
|
731
|
+
const fields = [];
|
|
732
|
+
for (const q of data.questions ?? []) {
|
|
733
|
+
const field = el('label', 'question-field');
|
|
734
|
+
field.appendChild(el('span', 'question-label', q.label));
|
|
735
|
+
let input;
|
|
736
|
+
if (q.type === 'textarea') {
|
|
737
|
+
input = document.createElement('textarea');
|
|
738
|
+
input.rows = 3;
|
|
739
|
+
} else if (q.type === 'select' || q.type === 'confirm') {
|
|
740
|
+
input = document.createElement('select');
|
|
741
|
+
const options = q.type === 'confirm' ? ['sí', 'no'] : q.options ?? [];
|
|
742
|
+
for (const opt of options) {
|
|
743
|
+
const option = document.createElement('option');
|
|
744
|
+
option.value = opt;
|
|
745
|
+
option.textContent = opt;
|
|
746
|
+
input.appendChild(option);
|
|
747
|
+
}
|
|
748
|
+
} else {
|
|
749
|
+
input = document.createElement('input');
|
|
750
|
+
input.type = 'text';
|
|
751
|
+
}
|
|
752
|
+
if (q.placeholder && 'placeholder' in input) input.placeholder = q.placeholder;
|
|
753
|
+
input.name = q.id;
|
|
754
|
+
input.className = 'question-input';
|
|
755
|
+
field.appendChild(input);
|
|
756
|
+
form.appendChild(field);
|
|
757
|
+
fields.push({ q, input });
|
|
758
|
+
}
|
|
759
|
+
const actions = el('div', 'question-actions');
|
|
760
|
+
const status = el('span', 'muted', '');
|
|
761
|
+
const submit = button('enviar', () => {});
|
|
762
|
+
submit.type = 'submit';
|
|
763
|
+
submit.classList.add('btn', 'primary');
|
|
764
|
+
actions.append(status, el('span', 'spacer'), submit);
|
|
765
|
+
form.appendChild(actions);
|
|
766
|
+
card.appendChild(form);
|
|
767
|
+
(container.tools ?? container).appendChild(card);
|
|
768
|
+
scrollMessages(paneOf(container));
|
|
769
|
+
state.questionCards.set(data.askId, { card, fields, status });
|
|
770
|
+
|
|
771
|
+
const settle = (text, klass) => {
|
|
772
|
+
card.classList.add(klass);
|
|
773
|
+
status.textContent = text;
|
|
774
|
+
submit.disabled = true;
|
|
775
|
+
for (const { input } of fields) input.disabled = true;
|
|
776
|
+
state.pendingQuestions.delete(data.askId);
|
|
777
|
+
renderAlertSignals();
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
form.addEventListener('submit', async (event) => {
|
|
781
|
+
event.preventDefault();
|
|
782
|
+
const answers = {};
|
|
783
|
+
for (const { q, input } of fields) {
|
|
784
|
+
answers[q.id] = q.type === 'confirm' ? input.value === 'sí' : input.value;
|
|
785
|
+
}
|
|
786
|
+
submit.disabled = true;
|
|
787
|
+
try {
|
|
788
|
+
await api(`/api/questions/${encodeURIComponent(data.askId)}`, { method: 'POST', body: { answers } });
|
|
789
|
+
settle('enviado', 'answered');
|
|
790
|
+
} catch (error) {
|
|
791
|
+
if (error?.status === 404) {
|
|
792
|
+
settle('ya no aplica', 'answered');
|
|
793
|
+
reconcile({ immediate: true });
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
submit.disabled = false;
|
|
797
|
+
status.textContent = `no se pudo enviar: ${error.message}`;
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
return card;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Formulario pendiente recreado tras un reload (no se reconstruye de mensajes). */
|
|
804
|
+
export function showStandaloneQuestion(record, ask) {
|
|
805
|
+
if (liveQuestion(ask.askId)) return;
|
|
806
|
+
record.el.querySelector('.empty')?.remove();
|
|
807
|
+
const holder = addMessage('assistant', '', record.el);
|
|
808
|
+
holder.textEl.remove();
|
|
809
|
+
addQuestionCard(holder, { askId: ask.askId, title: ask.title, questions: ask.questions });
|
|
810
|
+
state.pendingQuestions.set(ask.askId, { sessionId: record.id });
|
|
811
|
+
renderAlertSignals();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/* --- botones de opcion (offer_choices) ------------------------------------ */
|
|
815
|
+
|
|
816
|
+
export function sendChoice(label) {
|
|
817
|
+
chatInput.value = label;
|
|
818
|
+
sendMessage();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Botones tappables: al tocar uno se manda como mensaje del usuario. */
|
|
822
|
+
export function addChoiceCard(container, { question, options } = {}) {
|
|
823
|
+
const items = Array.isArray(options) ? options.map((o) => String(o).trim()).filter(Boolean).slice(0, 6) : [];
|
|
824
|
+
if (!items.length || !container) return null;
|
|
825
|
+
const card = el('div', 'choice-card');
|
|
826
|
+
if (question) card.appendChild(el('div', 'choice-question', question));
|
|
827
|
+
const row = el('div', 'choice-options');
|
|
828
|
+
for (const label of items) row.appendChild(button(label, () => sendChoice(label), 'btn choice-btn'));
|
|
829
|
+
card.appendChild(row);
|
|
830
|
+
(container.tools ?? container).appendChild(card);
|
|
831
|
+
scrollMessages(paneOf(container));
|
|
832
|
+
return card;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/* --- plan/checklist (show_plan) ------------------------------------------- */
|
|
836
|
+
|
|
837
|
+
export const PLAN_MARKS = { done: '✓', active: '◐', pending: '○' };
|
|
838
|
+
|
|
839
|
+
/** Checklist interactivo: el modelo lo reemite para actualizar el estado. */
|
|
840
|
+
export function addPlanCard(container, { title, steps } = {}) {
|
|
841
|
+
const items = Array.isArray(steps)
|
|
842
|
+
? steps.filter((s) => s && String(s.text ?? '').trim()).slice(0, 20)
|
|
843
|
+
: [];
|
|
844
|
+
if (!items.length || !container) return null;
|
|
845
|
+
const card = el('div', 'plan-card');
|
|
846
|
+
if (title) card.appendChild(el('div', 'plan-title', String(title)));
|
|
847
|
+
const list = el('ul', 'plan-steps');
|
|
848
|
+
for (const step of items) {
|
|
849
|
+
const status = ['done', 'active', 'pending'].includes(step.status) ? step.status : 'pending';
|
|
850
|
+
const li = el('li', `plan-step ${status}`);
|
|
851
|
+
li.append(el('span', 'plan-mark', PLAN_MARKS[status]), el('span', 'plan-text', String(step.text)));
|
|
852
|
+
list.appendChild(li);
|
|
853
|
+
}
|
|
854
|
+
card.appendChild(list);
|
|
855
|
+
(container.tools ?? container).appendChild(card);
|
|
856
|
+
scrollMessages(paneOf(container));
|
|
857
|
+
return card;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function userText(content) {
|
|
861
|
+
if (typeof content === 'string') return content;
|
|
862
|
+
if (Array.isArray(content)) {
|
|
863
|
+
return content
|
|
864
|
+
.filter((part) => part?.type === 'text')
|
|
865
|
+
.map((part) => part.text)
|
|
866
|
+
.join('\n');
|
|
867
|
+
}
|
|
868
|
+
return content == null ? '' : JSON.stringify(content);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export function userImages(content) {
|
|
872
|
+
if (!Array.isArray(content)) return [];
|
|
873
|
+
return content
|
|
874
|
+
.filter((part) => part?.type === 'image_url')
|
|
875
|
+
.map((part) => part.image_url?.url)
|
|
876
|
+
.filter(Boolean);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export const ADVISOR_KINDS = {
|
|
880
|
+
code: { label: 'Cuidado!', glyph: '⚠' },
|
|
881
|
+
explain: { label: 'Importante!', glyph: 'ℹ' },
|
|
882
|
+
advice: { label: 'Consejo', glyph: '✦' },
|
|
883
|
+
summary: { label: 'Resumen', glyph: '✎' },
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
export function advisorMeta(kind) {
|
|
887
|
+
return ADVISOR_KINDS[kind] || { label: 'Asesor', glyph: '✦' };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
|
|
891
|
+
export function addAdvisorCard(container, { text, kind } = {}) {
|
|
892
|
+
const clean = String(text ?? '').trim();
|
|
893
|
+
if (!clean || !container) return null;
|
|
894
|
+
const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
|
|
895
|
+
if (exists) return null;
|
|
896
|
+
const meta = advisorMeta(kind);
|
|
897
|
+
const klass = ADVISOR_KINDS[kind] ? ` advisor-${kind}` : '';
|
|
898
|
+
const card = el('div', `advisor-card${klass}`);
|
|
899
|
+
const head = el('div', 'advisor-head');
|
|
900
|
+
head.append(
|
|
901
|
+
el('span', 'advisor-glyph', meta.glyph),
|
|
902
|
+
el('span', 'advisor-title', 'asesor'),
|
|
903
|
+
el('span', `advisor-badge advisor-badge-${kind || 'advice'}`, meta.label)
|
|
904
|
+
);
|
|
905
|
+
card.append(head, el('div', 'advisor-text', clean));
|
|
906
|
+
container.appendChild(card);
|
|
907
|
+
return card;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Marca del punto de compactación: una línea que corta el historial y, debajo,
|
|
912
|
+
* el resumen que pasa a ser el contexto del modelo. Lo de arriba sigue visible
|
|
913
|
+
* como histórico; al modelo solo le llega lo de abajo (más los mensajes nuevos).
|
|
914
|
+
*/
|
|
915
|
+
export function addCompactCard(container, { text, label, running = false } = {}) {
|
|
916
|
+
if (!container) return null;
|
|
917
|
+
const card = el('div', `compact-card${running ? ' running' : ''}`);
|
|
918
|
+
const rule = el('div', 'compact-rule', label ?? (running ? 'compactando…' : 'contexto compactado'));
|
|
919
|
+
card.append(rule);
|
|
920
|
+
if (running) {
|
|
921
|
+
// La compactación puede tardar (el modelo resume el historial): mostramos el
|
|
922
|
+
// corte con un loader animado hasta que el resumen llegue.
|
|
923
|
+
rule.append(el('span', 'loader loader-thinking'));
|
|
924
|
+
} else {
|
|
925
|
+
const clean = String(text ?? '').trim();
|
|
926
|
+
if (clean) {
|
|
927
|
+
const body = el('div', 'compact-text text');
|
|
928
|
+
body.innerHTML = renderMarkdown(clean);
|
|
929
|
+
card.append(body);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
container.appendChild(card);
|
|
933
|
+
return card;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Completa la tarjeta de compactación en curso con el resumen del modelo. */
|
|
937
|
+
export function finishCompactCard(card, text) {
|
|
938
|
+
if (!card) return;
|
|
939
|
+
card.classList.remove('running');
|
|
940
|
+
const rule = card.querySelector('.compact-rule');
|
|
941
|
+
if (rule) rule.textContent = 'contexto compactado';
|
|
942
|
+
const clean = String(text ?? '').trim();
|
|
943
|
+
if (clean && !card.querySelector('.compact-text')) {
|
|
944
|
+
const body = el('div', 'compact-text text');
|
|
945
|
+
body.innerHTML = renderMarkdown(clean);
|
|
946
|
+
card.append(body);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
export function renderStored(message, pane) {
|
|
951
|
+
if (message.agent_id) return;
|
|
952
|
+
if (message.name === 'compact') {
|
|
953
|
+
addCompactCard(pane, { text: typeof message.content === 'string' ? message.content : '' });
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (message.role === 'assistant' && message.name === 'advisor') {
|
|
957
|
+
addAdvisorCard(pane, {
|
|
958
|
+
text: typeof message.content === 'string' ? message.content : '',
|
|
959
|
+
kind: message.mode,
|
|
960
|
+
});
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (message.role === 'user') {
|
|
964
|
+
const bubble = addMessage('user', userText(message.content), pane);
|
|
965
|
+
if (message.id) {
|
|
966
|
+
bubble.id = message.id;
|
|
967
|
+
bubble.wrap.dataset.messageId = message.id;
|
|
968
|
+
}
|
|
969
|
+
appendImages(bubble, userImages(message.content));
|
|
970
|
+
} else if (message.role === 'assistant') {
|
|
971
|
+
const bubble = addMessage('assistant', typeof message.content === 'string' ? message.content : '', pane);
|
|
972
|
+
if (message.mode) setMessageMode(bubble, message.mode);
|
|
973
|
+
if (message.id) {
|
|
974
|
+
bubble.id = message.id;
|
|
975
|
+
bubble.wrap.dataset.messageId = message.id;
|
|
976
|
+
}
|
|
977
|
+
for (const call of message.tool_calls ?? []) {
|
|
978
|
+
const name = call.function?.name ?? call.name;
|
|
979
|
+
const args = parseToolArgs(call);
|
|
980
|
+
const tool = addToolCard(bubble, name, args, { callId: call.id, open: false });
|
|
981
|
+
tool.card.classList.remove('running');
|
|
982
|
+
tool.card.classList.add('done');
|
|
983
|
+
tool.glyph.textContent = '✓';
|
|
984
|
+
if (call.id) toolCardIndex.set(call.id, tool);
|
|
985
|
+
if (name === 'offer_choices') addChoiceCard(bubble, { question: args.question, options: args.options });
|
|
986
|
+
if (name === 'show_plan') addPlanCard(bubble, { title: args.title, steps: args.steps });
|
|
987
|
+
}
|
|
988
|
+
} else if (message.role === 'tool') {
|
|
989
|
+
const tool = toolCardIndex.get(message.tool_call_id);
|
|
990
|
+
if (!tool) return;
|
|
991
|
+
const { preview, truncated } = previewText(message.content);
|
|
992
|
+
finishToolCard(tool, { preview, truncated, failed: isFailedToolText(message.content) });
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export function renderStoredMessages(messages, pane) {
|
|
997
|
+
toolCardIndex.clear();
|
|
998
|
+
for (const message of messages) renderStored(message, pane);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export function basename(p) {
|
|
1002
|
+
const text = String(p ?? '').replace(/[\\/]+$/, '');
|
|
1003
|
+
return text.split(/[\\/]/).pop() || text;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Pestañas cerradas desde el último reporte: para que el server las olvide ya,
|
|
1007
|
+
// sin esperar el TTL de presencia.
|
|
1008
|
+
export const presenceClosed = new Set();
|
|
1009
|
+
|
|
1010
|
+
/** Reporta al server qué pestañas están abiertas (proyectos "habilitados"). */
|
|
1011
|
+
export function sendPresence() {
|
|
1012
|
+
const closed = [...presenceClosed];
|
|
1013
|
+
presenceClosed.clear();
|
|
1014
|
+
return api('/api/presence', { method: 'POST', body: { sessions: state.tabOrder, closed } }).catch(() => {});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export function persistTabs() {
|
|
1018
|
+
storage.setJson('tamnora-open-tabs', { ids: state.tabOrder, active: state.activeTab });
|
|
1019
|
+
sendPresence();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Dibuja el sidebar desde el ultimo snapshot.
|
|
1024
|
+
*
|
|
1025
|
+
* Antes hacia su propio `GET /api/sessions`: dos caminos de datos que se
|
|
1026
|
+
* contradician. Ahora el unico que habla con el server es `reconcile`.
|
|
1027
|
+
*/
|
|
1028
|
+
export function renderSessions(sessions) {
|
|
1029
|
+
const list = sessions ?? [];
|
|
1030
|
+
clear(sessionsEl);
|
|
1031
|
+
const groups = new Map();
|
|
1032
|
+
for (const session of list) {
|
|
1033
|
+
const key = session.cwd || '';
|
|
1034
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
1035
|
+
groups.get(key).push(session);
|
|
1036
|
+
}
|
|
1037
|
+
for (const [key, items] of groups) {
|
|
1038
|
+
const groupLi = el('li', 'session-group');
|
|
1039
|
+
const head = el('div', 'group-head');
|
|
1040
|
+
const open = state.expandedGroups.has(key);
|
|
1041
|
+
head.classList.toggle('open', open);
|
|
1042
|
+
const twisty = el('span', 'tw');
|
|
1043
|
+
twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
|
|
1044
|
+
head.append(twisty, icon('folder', 15, 'fi'), el('span', 'gname', key ? basename(key) : 'sin carpeta'), el('span', 'gcount', String(items.length)));
|
|
1045
|
+
head.title = key || 'sesiones sin carpeta';
|
|
1046
|
+
const delGroup = el('button', 'group-del');
|
|
1047
|
+
delGroup.type = 'button';
|
|
1048
|
+
delGroup.title = 'eliminar proyecto (borra sus sesiones, no los archivos)';
|
|
1049
|
+
delGroup.setAttribute('aria-label', 'eliminar proyecto');
|
|
1050
|
+
delGroup.appendChild(icon('trash', 13));
|
|
1051
|
+
delGroup.addEventListener('click', (event) => {
|
|
1052
|
+
event.stopPropagation();
|
|
1053
|
+
removeProject(key, items.length);
|
|
1054
|
+
});
|
|
1055
|
+
head.appendChild(delGroup);
|
|
1056
|
+
const list = el('ul', 'group-sessions');
|
|
1057
|
+
list.hidden = !open;
|
|
1058
|
+
head.addEventListener('click', () => {
|
|
1059
|
+
const nowOpen = !state.expandedGroups.has(key);
|
|
1060
|
+
if (nowOpen) state.expandedGroups.add(key);
|
|
1061
|
+
else state.expandedGroups.delete(key);
|
|
1062
|
+
head.classList.toggle('open', nowOpen);
|
|
1063
|
+
list.hidden = !nowOpen;
|
|
1064
|
+
clear(twisty);
|
|
1065
|
+
twisty.appendChild(icon(nowOpen ? 'chevron-down' : 'chevron-right', 14));
|
|
1066
|
+
});
|
|
1067
|
+
for (const session of items) {
|
|
1068
|
+
const li = el('li', `session-item${session.id === state.sessionId ? ' active' : ''}`);
|
|
1069
|
+
const main = el('div', 'session-main');
|
|
1070
|
+
main.appendChild(el('span', 'session-title', session.title || '(sin título)'));
|
|
1071
|
+
const meta = [session.provider, session.model].filter(Boolean).join(' / ');
|
|
1072
|
+
if (meta) main.appendChild(el('span', 'session-sub', meta));
|
|
1073
|
+
li.appendChild(main);
|
|
1074
|
+
// Punto de actividad: el server lo dice en el listado, y el reconciliador
|
|
1075
|
+
// lo mantiene al dia si el run arranca despues de este render. El estado
|
|
1076
|
+
// "esperandote a vos" tiene su propio color: un run trabajando en otra
|
|
1077
|
+
// pestana no necesita tu atencion, un permiso si.
|
|
1078
|
+
const awaiting = session.awaiting || [...state.pendingAsks.values()].filter((ask) => ask.sessionId === session.id).length;
|
|
1079
|
+
if (awaiting) {
|
|
1080
|
+
const dot = el('span', 'session-await');
|
|
1081
|
+
dot.title = `${awaiting} permiso(s) esperando tu decision`;
|
|
1082
|
+
li.appendChild(dot);
|
|
1083
|
+
}
|
|
1084
|
+
if (session.running || state.remoteActive.has(session.id)) {
|
|
1085
|
+
const dot = el('span', 'session-live');
|
|
1086
|
+
dot.title = 'el agente está trabajando en esta sesión';
|
|
1087
|
+
li.appendChild(dot);
|
|
1088
|
+
}
|
|
1089
|
+
li.title = `${session.provider ?? ''}/${session.model ?? ''}${session.cwd ? ` · ${session.cwd}` : ''}`;
|
|
1090
|
+
const del = el('button', 'session-del');
|
|
1091
|
+
del.type = 'button';
|
|
1092
|
+
del.title = 'eliminar sesión';
|
|
1093
|
+
del.setAttribute('aria-label', `eliminar sesión ${session.title || ''}`);
|
|
1094
|
+
del.appendChild(icon('trash', 13));
|
|
1095
|
+
del.addEventListener('click', (event) => {
|
|
1096
|
+
event.stopPropagation();
|
|
1097
|
+
removeSession(session);
|
|
1098
|
+
});
|
|
1099
|
+
li.appendChild(del);
|
|
1100
|
+
li.addEventListener('click', () => {
|
|
1101
|
+
if (isMobile()) setSidebarOpen(false);
|
|
1102
|
+
openTab(session.id);
|
|
1103
|
+
});
|
|
1104
|
+
list.appendChild(li);
|
|
1105
|
+
}
|
|
1106
|
+
groupLi.append(head, list);
|
|
1107
|
+
sessionsEl.appendChild(groupLi);
|
|
1108
|
+
}
|
|
1109
|
+
if (!list.length) sessionsEl.appendChild(el('li', 'muted', 'sin sesiones'));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Pedir el estado fresco. Es un alias de `reconcile` que queda para los ~20
|
|
1114
|
+
* call sites que ya existian (despues de mandar un mensaje, borrar, etc.).
|
|
1115
|
+
*/
|
|
1116
|
+
export function refreshSessions() {
|
|
1117
|
+
reconcile({ immediate: true });
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
export function closeSessionsOf(cwd) {
|
|
1121
|
+
for (const [id, record] of [...state.panes]) {
|
|
1122
|
+
if ((record.cwd || '') === (cwd || '')) closeTab(id);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** confirm + DELETE + notify + refresh: patron repetido en todo el panel. */
|
|
1127
|
+
export async function deleteEntity(url, { confirmText, label = 'eliminar', after = null, refresh = null }) {
|
|
1128
|
+
if (!(await showConfirm(confirmText, { title: 'Confirmar', confirmLabel: label, danger: true }))) return;
|
|
1129
|
+
const error = await api(url, { method: 'DELETE' }).then(() => null).catch((e) => e);
|
|
1130
|
+
if (error) return notify(`No se pudo ${label}: ${error.message}`, 'notice err');
|
|
1131
|
+
await after?.();
|
|
1132
|
+
await refresh?.();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Import en un input de texto + status + refresh (MCP y skills). */
|
|
1136
|
+
export async function importEntity({ url, body, input, statusSel, refresh, okMessage }) {
|
|
1137
|
+
setStatus(statusSel, 'importando…');
|
|
1138
|
+
const data = await api(url, { method: 'POST', body }).catch((e) => ({ error: e.message }));
|
|
1139
|
+
if (data?.error) return setStatus(statusSel, data.error, 'err');
|
|
1140
|
+
input.value = '';
|
|
1141
|
+
setStatus(statusSel, okMessage(data), 'ok');
|
|
1142
|
+
await refresh?.();
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** Cambia el estado enabled de una entidad (PUT de cuerpo completo o POST toggle). */
|
|
1146
|
+
export async function toggleEntity(url, { method = 'PUT', body = undefined, errorLabel, refresh }) {
|
|
1147
|
+
await api(url, { method, body }).catch((error) =>
|
|
1148
|
+
notify(`No se pudo ${errorLabel}: ${error.message}`, 'notice err')
|
|
1149
|
+
);
|
|
1150
|
+
await refresh?.();
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
export async function removeSession(session) {
|
|
1154
|
+
const name = session.title || '(sin título)';
|
|
1155
|
+
await deleteEntity(`/api/sessions/${session.id}`, {
|
|
1156
|
+
confirmText: `¿Eliminar la sesión "${name}"? No se puede deshacer.`,
|
|
1157
|
+
after: () => {
|
|
1158
|
+
if (state.panes.has(session.id)) closeTab(session.id);
|
|
1159
|
+
},
|
|
1160
|
+
refresh: refreshSessions,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
export async function removeProject(cwd, count) {
|
|
1165
|
+
const label = cwd ? basename(cwd) : 'sin carpeta';
|
|
1166
|
+
await deleteEntity(`/api/projects?cwd=${encodeURIComponent(cwd ?? '')}`, {
|
|
1167
|
+
confirmText: `¿Eliminar ${count} sesión(es) del proyecto "${label}"? No se tocan los archivos del disco.`,
|
|
1168
|
+
label: 'eliminar el proyecto',
|
|
1169
|
+
after: () => closeSessionsOf(cwd),
|
|
1170
|
+
refresh: refreshSessions,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
export function showPane(id) {
|
|
1175
|
+
const record = id ? state.panes.get(id) : null;
|
|
1176
|
+
for (const item of state.panes.values()) item.el.hidden = item.id !== id;
|
|
1177
|
+
blankPane.hidden = Boolean(record);
|
|
1178
|
+
activePane = record ? record.el : blankPane;
|
|
1179
|
+
renderFooterActivity(record?.working ? 'thinking' : null);
|
|
1180
|
+
scheduleScrollNav();
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
export const INIT_PROMPT =
|
|
1184
|
+
'Analizá este proyecto (estructura, stack, convenciones, comandos de build/test) y creá o actualizá un archivo AGENTS.md en la raíz con instrucciones claras para futuros agentes. Inspeccioná archivos reales antes de escribir.';
|
|
1185
|
+
|
|
1186
|
+
export function createChatTurn(pane, sessionKey) {
|
|
1187
|
+
const turn = {
|
|
1188
|
+
pane,
|
|
1189
|
+
sessionKey,
|
|
1190
|
+
// Turno iniciado desde esta consola: solo los turnos propios hablan (el
|
|
1191
|
+
// replay de una reconexion o de otra pestaña no narran el pasado).
|
|
1192
|
+
live: false,
|
|
1193
|
+
active: null,
|
|
1194
|
+
sawText: false,
|
|
1195
|
+
// Cursor del log del run (`id:` del ultimo frame): reanuda desde aca.
|
|
1196
|
+
cursor: 0,
|
|
1197
|
+
// Si esta consola ya dibujo el mensaje del usuario de este turno.
|
|
1198
|
+
sawUser: false,
|
|
1199
|
+
lastTool: null,
|
|
1200
|
+
startNewOnText: false,
|
|
1201
|
+
agents: new Map(),
|
|
1202
|
+
turnMode: modeSelect.value || 'agent',
|
|
1203
|
+
assistant() {
|
|
1204
|
+
if (!turn.active) {
|
|
1205
|
+
turn.active = addMessage('assistant', '', turn.pane);
|
|
1206
|
+
setMessageMode(turn.active, turn.turnMode);
|
|
1207
|
+
}
|
|
1208
|
+
return turn.active;
|
|
1209
|
+
},
|
|
1210
|
+
flowTarget() {
|
|
1211
|
+
if (turn.startNewOnText) {
|
|
1212
|
+
turn.active = addMessage('assistant', '', turn.pane);
|
|
1213
|
+
setMessageMode(turn.active, turn.turnMode);
|
|
1214
|
+
turn.startNewOnText = false;
|
|
1215
|
+
setActivity(turn.active, 'thinking');
|
|
1216
|
+
}
|
|
1217
|
+
return turn.assistant();
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
return turn;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export function addAgentCard(message, data) {
|
|
1224
|
+
const card = el('details', 'agent-card running');
|
|
1225
|
+
const head = el('summary', 'agent-head');
|
|
1226
|
+
const glyph = el('span', 'agent-glyph', '⏺');
|
|
1227
|
+
const title = el('span', 'agent-title', `subagente · ${data.description ?? 'tarea'}`);
|
|
1228
|
+
const status = el('span', 'agent-status', data.mode === 'plan' ? 'solo lectura' : 'agente');
|
|
1229
|
+
head.append(glyph, title, status);
|
|
1230
|
+
const body = el('div', 'agent-body');
|
|
1231
|
+
const reasoning = el('details', 'reasoning');
|
|
1232
|
+
reasoning.appendChild(el('summary', '', 'razonamiento'));
|
|
1233
|
+
const reasoningEl = el('div', 'body');
|
|
1234
|
+
reasoning.appendChild(reasoningEl);
|
|
1235
|
+
reasoning.hidden = true;
|
|
1236
|
+
const textEl = el('div', 'agent-text');
|
|
1237
|
+
const toolsEl = el('div', 'agent-tools');
|
|
1238
|
+
body.append(reasoning, textEl, toolsEl);
|
|
1239
|
+
card.append(head, body);
|
|
1240
|
+
message.tools.appendChild(card);
|
|
1241
|
+
ensureToolsToggle(message);
|
|
1242
|
+
scrollMessages(paneOf(message));
|
|
1243
|
+
const pseudo = { tools: toolsEl, bubble: card, toolsToggle: null };
|
|
1244
|
+
return {
|
|
1245
|
+
card,
|
|
1246
|
+
glyph,
|
|
1247
|
+
statusEl: status,
|
|
1248
|
+
textEl,
|
|
1249
|
+
reasoning,
|
|
1250
|
+
reasoningEl,
|
|
1251
|
+
toolsEl,
|
|
1252
|
+
message: pseudo,
|
|
1253
|
+
lastTool: null,
|
|
1254
|
+
sawText: false,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export function handleAgentEvent(event, data, turn) {
|
|
1259
|
+
if (!data?.agentId) return false;
|
|
1260
|
+
const agent = turn.agents.get(data.agentId);
|
|
1261
|
+
if (!agent) return false;
|
|
1262
|
+
if (event === 'text') {
|
|
1263
|
+
agent.sawText = true;
|
|
1264
|
+
agent.textEl.innerHTML = renderMarkdown(data.full);
|
|
1265
|
+
scrollMessages(paneOf(agent.message));
|
|
1266
|
+
} else if (event === 'reasoning') {
|
|
1267
|
+
agent.reasoning.hidden = false;
|
|
1268
|
+
if (!agent.sawText) agent.reasoningEl.textContent = data.full;
|
|
1269
|
+
} else if (event === 'tool_call') {
|
|
1270
|
+
agent.lastTool = addToolCard(agent.message, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
1271
|
+
} else if (event === 'tool_result') {
|
|
1272
|
+
finishToolCard(agent.lastTool, data);
|
|
1273
|
+
} else {
|
|
1274
|
+
return false;
|
|
1275
|
+
}
|
|
1276
|
+
return true;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
export function formatDuration(ms) {
|
|
1280
|
+
const value = Number(ms) || 0;
|
|
1281
|
+
if (value <= 0) return '';
|
|
1282
|
+
return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** Cierre del turno: siempre deja un resumen visible (rondas, tools, tiempo). */
|
|
1286
|
+
export function renderCompletion(message, data) {
|
|
1287
|
+
const tools = Number(data.toolCalls) || 0;
|
|
1288
|
+
const parts = [`${data.rounds ?? '?'} ronda${data.rounds === 1 ? '' : 's'}`];
|
|
1289
|
+
if (tools) parts.push(`${tools} herramienta${tools === 1 ? '' : 's'}`);
|
|
1290
|
+
const duration = formatDuration(data.durationMs);
|
|
1291
|
+
if (duration) parts.push(duration);
|
|
1292
|
+
if (data.tokens?.total_tokens) parts.push(`${Number(data.tokens.total_tokens).toLocaleString('es')} tokens`);
|
|
1293
|
+
const cut = ['max_rounds', 'no_progress', 'budget'].includes(data.stopReason);
|
|
1294
|
+
const label = cut ? (data.wrappedUp ? '⚠ corte de seguridad · resumen' : '⚠ corte de seguridad') : '✓ terminado';
|
|
1295
|
+
message.tools.appendChild(el('div', cut ? 'chip warn' : 'chip ok', `${label} · ${parts.join(' · ')}`));
|
|
1296
|
+
scrollMessages(paneOf(message));
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** Retoma un turno cortado por límite/bucle sin tener que escribir "continúa". */
|
|
1300
|
+
export function addContinueButton(turn, data) {
|
|
1301
|
+
const btn = el('button', 'continue-btn', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
|
|
1302
|
+
btn.type = 'button';
|
|
1303
|
+
btn.addEventListener('click', () => {
|
|
1304
|
+
if (state.panes.get(turn.sessionKey)?.working) return;
|
|
1305
|
+
chatInput.value = 'Continuá donde quedaste.';
|
|
1306
|
+
sendMessage();
|
|
1307
|
+
});
|
|
1308
|
+
turn.assistant().tools.appendChild(btn);
|
|
1309
|
+
scrollMessages(paneOf(turn.assistant()));
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export function handleChatEvent(event, data, turn) {
|
|
1313
|
+
if (handleAgentEvent(event, data, turn)) return;
|
|
1314
|
+
narrar(event, data, turn);
|
|
1315
|
+
if (event === 'advice') {
|
|
1316
|
+
addAdvisorCard(turn.pane, { text: data?.text, kind: data?.kind });
|
|
1317
|
+
scrollMessages(turn.pane);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (event === 'agent_start') {
|
|
1321
|
+
turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
|
|
1322
|
+
} else if (event === 'agent_end') {
|
|
1323
|
+
const agent = turn.agents.get(data.agentId);
|
|
1324
|
+
if (agent) {
|
|
1325
|
+
agent.card.classList.remove('running');
|
|
1326
|
+
agent.card.classList.add(data.status === 'error' ? 'failed' : 'done');
|
|
1327
|
+
agent.glyph.textContent = data.status === 'error' ? '✗' : '✓';
|
|
1328
|
+
agent.statusEl.textContent = data.status;
|
|
1329
|
+
if (data.content && !agent.sawText) agent.textEl.textContent = data.content;
|
|
1330
|
+
}
|
|
1331
|
+
} else if (event === 'start') {
|
|
1332
|
+
if (data.cwd) setCwd(data.cwd);
|
|
1333
|
+
if (data.mode) turn.turnMode = data.mode;
|
|
1334
|
+
if (turn.active) setMessageMode(turn.active, turn.turnMode);
|
|
1335
|
+
} else if (event === 'user') {
|
|
1336
|
+
// Otro cliente (el celular, Telegram) mando esto: hay que dibujarlo, pero
|
|
1337
|
+
// sin duplicar lo que esta consola ya renderizo o lo que vino de SQLite.
|
|
1338
|
+
const content = String(data?.content ?? '');
|
|
1339
|
+
if (turn.sawUser || !content) return;
|
|
1340
|
+
if (lastMessageMatches(turn.pane, 'user', content)) {
|
|
1341
|
+
turn.sawUser = true;
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
addMessage('user', content, turn.pane);
|
|
1345
|
+
turn.sawUser = true;
|
|
1346
|
+
} else if (event === 'text') {
|
|
1347
|
+
turn.sawText = true;
|
|
1348
|
+
const target = turn.flowTarget();
|
|
1349
|
+
setText(target, data.full);
|
|
1350
|
+
setActivity(target, 'thinking');
|
|
1351
|
+
} else if (event === 'reasoning') {
|
|
1352
|
+
const target = turn.flowTarget();
|
|
1353
|
+
if (!turn.sawText) ensureReasoning(target).textContent = data.full;
|
|
1354
|
+
setActivity(target, 'thinking');
|
|
1355
|
+
} else if (event === 'tools') {
|
|
1356
|
+
turn.startNewOnText = true;
|
|
1357
|
+
} else if (event === 'tool_call') {
|
|
1358
|
+
const target = turn.assistant();
|
|
1359
|
+
setActivity(target, 'working');
|
|
1360
|
+
turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
1361
|
+
if (turn.live && data.name === 'speak') {
|
|
1362
|
+
const texto = String(data.args?.texto ?? '').trim();
|
|
1363
|
+
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, pitch: data.args?.pitch, label: 'voz' }).catch(() => {});
|
|
1364
|
+
}
|
|
1365
|
+
if (turn.live && data.name === 'preview_url' && data.args?.url) {
|
|
1366
|
+
setExplorerMode('preview');
|
|
1367
|
+
// Desde otro dispositivo, 127.0.0.1 apunta al dispositivo: si el agente
|
|
1368
|
+
// pidio una URL local, la publicamos por tailscale/tunel.
|
|
1369
|
+
const port = isLocalHost() ? 0 : localPortOf(data.args.url);
|
|
1370
|
+
if (port) openRemoteTunnel(port);
|
|
1371
|
+
else loadPreview(data.args.url);
|
|
1372
|
+
}
|
|
1373
|
+
if (turn.live && data.name === 'offer_choices') {
|
|
1374
|
+
addChoiceCard(target, { question: data.args?.question, options: data.args?.options });
|
|
1375
|
+
}
|
|
1376
|
+
if (turn.live && data.name === 'show_plan') {
|
|
1377
|
+
addPlanCard(target, { title: data.args?.title, steps: data.args?.steps });
|
|
1378
|
+
}
|
|
1379
|
+
} else if (event === 'tool_result') {
|
|
1380
|
+
setActivity(turn.assistant(), 'thinking');
|
|
1381
|
+
finishToolCard(turn.lastTool, data);
|
|
1382
|
+
} else if (event === 'question') {
|
|
1383
|
+
if (!liveQuestion(data.askId)) addQuestionCard(turn.assistant(), data);
|
|
1384
|
+
state.pendingQuestions.set(data.askId, { sessionId: data.sessionId ?? turn.sessionKey });
|
|
1385
|
+
renderAlertSignals();
|
|
1386
|
+
if (paneOf(turn.active) !== activePane) toast('Formulario pendiente en otra pestaña.');
|
|
1387
|
+
} else if (event === 'question_answered') {
|
|
1388
|
+
state.pendingQuestions.delete(data?.askId);
|
|
1389
|
+
renderAlertSignals();
|
|
1390
|
+
const entry = state.questionCards.get(data.askId);
|
|
1391
|
+
if (entry && !entry.card.classList.contains('answered')) {
|
|
1392
|
+
entry.card.classList.add('answered');
|
|
1393
|
+
entry.status.textContent = data?.cancelled ? 'cancelado' : 'respondido en otra ventana';
|
|
1394
|
+
for (const { input } of entry.fields) input.disabled = true;
|
|
1395
|
+
}
|
|
1396
|
+
} else if (event === 'permission') {
|
|
1397
|
+
// Si la tarjeta ya esta en el DOM (la creo `renderPane` antes del replay, o
|
|
1398
|
+
// llego por el canal global) no hay que crear OTRO mensaje del asistente
|
|
1399
|
+
// vacio para colgarla.
|
|
1400
|
+
const existing = livePermission(data.askId);
|
|
1401
|
+
const target = existing ? existing.message : turn.assistant();
|
|
1402
|
+
if (!existing) addPermissionCard(target, data);
|
|
1403
|
+
state.pendingAsks.set(data.askId, {
|
|
1404
|
+
sessionId: data.sessionId ?? turn.sessionKey,
|
|
1405
|
+
name: data.name ?? '',
|
|
1406
|
+
args: data.args ?? {},
|
|
1407
|
+
});
|
|
1408
|
+
renderAlertSignals();
|
|
1409
|
+
// La tarjeta vive solo en el chat de esta sesion. Si el permiso cae en otra
|
|
1410
|
+
// pestaña, sin este aviso la tarjeta queda enterrada y el run bloqueado
|
|
1411
|
+
// esperando una decision que nadie ve.
|
|
1412
|
+
if (paneOf(turn.active) !== activePane) {
|
|
1413
|
+
toast(`Permiso pendiente: ${data.name} en otra pestaña.`);
|
|
1414
|
+
}
|
|
1415
|
+
} else if (event === 'permission_resolved') {
|
|
1416
|
+
setActivity(turn.assistant(), 'thinking');
|
|
1417
|
+
state.pendingAsks.delete(data?.askId);
|
|
1418
|
+
renderAlertSignals();
|
|
1419
|
+
const entry = state.permissionCards.get(data.askId);
|
|
1420
|
+
// Solo entra aca si el veredicto sigue pendiente, o sea que **otra** ventana
|
|
1421
|
+
// (la PC, otro celular, Telegram) lo resolvio: decirlo evita que el celular
|
|
1422
|
+
// muestre "aprobado" como si hubiera sido su decision.
|
|
1423
|
+
if (entry && entry.verdict.textContent === 'requiere aprobación') {
|
|
1424
|
+
resolvePermissionCard(entry, data.allow);
|
|
1425
|
+
if (paneOf(turn.assistant()) === activePane) {
|
|
1426
|
+
toast(`Permiso ${data.allow ? 'aprobado' : 'denegado'} desde otra ventana.`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
} else if (event === 'stall') {
|
|
1430
|
+
if (!turn.sawText) setText(turn.assistant(), `⚠ ${data.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`);
|
|
1431
|
+
} else if (event === 'retry') {
|
|
1432
|
+
if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
|
|
1433
|
+
} else if (event === 'trim') {
|
|
1434
|
+
turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
|
|
1435
|
+
} else if (event === 'repeat') {
|
|
1436
|
+
turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
|
|
1437
|
+
} else if (event === 'usage') {
|
|
1438
|
+
setContextUsage(data.tokens);
|
|
1439
|
+
} else if (event === 'warning') {
|
|
1440
|
+
turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
|
|
1441
|
+
} else if (event === 'error') {
|
|
1442
|
+
setActivity(turn.active, null);
|
|
1443
|
+
turn.assistant().tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
|
|
1444
|
+
} else if (event === 'done') {
|
|
1445
|
+
setActivity(turn.active, null);
|
|
1446
|
+
const finalText = (turn.active?.textEl?.textContent ?? '').trim();
|
|
1447
|
+
const tools = Number(data.toolCalls) || 0;
|
|
1448
|
+
if (!finalText) {
|
|
1449
|
+
if (data.stopReason === 'no_progress') {
|
|
1450
|
+
setText(turn.assistant(), '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.');
|
|
1451
|
+
} else if (data.stopReason === 'budget') {
|
|
1452
|
+
setText(turn.assistant(), '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.');
|
|
1453
|
+
} else if (data.stopReason === 'max_rounds') {
|
|
1454
|
+
setText(
|
|
1455
|
+
turn.assistant(),
|
|
1456
|
+
`⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
|
|
1457
|
+
tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
|
|
1458
|
+
}`
|
|
1459
|
+
);
|
|
1460
|
+
} else if (tools) {
|
|
1461
|
+
setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
|
|
1462
|
+
} else {
|
|
1463
|
+
setText(turn.assistant(), '(sin texto)');
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
renderCompletion(turn.active, data);
|
|
1467
|
+
if (data.continuable) addContinueButton(turn, data);
|
|
1468
|
+
updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
|
|
1469
|
+
addTokens(data.tokens);
|
|
1470
|
+
} else if (event === 'aborted') {
|
|
1471
|
+
setActivity(turn.active, null);
|
|
1472
|
+
setText(turn.assistant(), '⏹ cancelado.');
|
|
1473
|
+
} else if (event === 'resumed') {
|
|
1474
|
+
// Reconexion al stream de la sesion: el server dice hasta donde replayo.
|
|
1475
|
+
turn.cursor = Number(data.cursor) || turn.cursor;
|
|
1476
|
+
if (data.active === false) closeTurn(turn, 'El turno ya había terminado.');
|
|
1477
|
+
} else if (event === 'idle') {
|
|
1478
|
+
// El server cerro el stream porque no hay nada corriendo.
|
|
1479
|
+
closeTurn(turn, '');
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Cierra un turno cuyo stream se agoto sin `done`: deja el mensaje en un estado
|
|
1485
|
+
* coherente en vez de colgado en "pensando...".
|
|
1486
|
+
*/
|
|
1487
|
+
export function closeTurn(turn, text) {
|
|
1488
|
+
setActivity(turn.active, null);
|
|
1489
|
+
const message = turn.assistant();
|
|
1490
|
+
if (!text) return;
|
|
1491
|
+
if ((message.textEl?.textContent ?? '').trim()) return;
|
|
1492
|
+
setText(message, text);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* El mensaje del usuario se emite antes de persistirse (routes/chat.js), asi que
|
|
1497
|
+
* un cliente que se engancha a mitad de run no lo tiene todavia. Y al reconectar
|
|
1498
|
+
* el mismo evento se vuelve a reproducir, cuando `renderPane` ya lo dibujo desde
|
|
1499
|
+
* SQLite: por eso la doble guarda.
|
|
1500
|
+
*/
|
|
1501
|
+
export function lastMessageMatches(pane, role, text) {
|
|
1502
|
+
const nodes = pane.querySelectorAll(':scope > .msg');
|
|
1503
|
+
const last = nodes[nodes.length - 1];
|
|
1504
|
+
if (!last || !last.classList.contains(role)) return false;
|
|
1505
|
+
return last.textContent.trim() === String(text ?? '').trim();
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Dueña de un run: mantiene el stream vivo hasta que el turno termina.
|
|
1510
|
+
*
|
|
1511
|
+
* Un corte del tunel (4G, pantalla bloqueada, proxy) deja el `fetch` colgado
|
|
1512
|
+
* sin error, asi que reconectamos con backoff desde el ultimo cursor en vez de
|
|
1513
|
+
* dejar la UI en "pensando..." para siempre. El server honra `?from=`, asi que
|
|
1514
|
+
* reanudar no duplica eventos.
|
|
1515
|
+
*
|
|
1516
|
+
* `rerender` solo aplica al primer intento: al reconectar NO hay que volver a
|
|
1517
|
+
* pintar el pane, porque borraria el mensaje del asistente en vuelo y sus
|
|
1518
|
+
* tarjetas de tools.
|
|
1519
|
+
*/
|
|
1520
|
+
export async function followRunSession(sessionKey, turn, { from = 0, rerender = false, attempts = 6 } = {}) {
|
|
1521
|
+
const record = state.panes.get(sessionKey);
|
|
1522
|
+
const controller = new AbortController();
|
|
1523
|
+
controllers.set(sessionKey, controller);
|
|
1524
|
+
if (record) setWorking(sessionKey, true);
|
|
1525
|
+
let needsRerender = rerender;
|
|
1526
|
+
let chip = null;
|
|
1527
|
+
|
|
1528
|
+
try {
|
|
1529
|
+
await followRun({
|
|
1530
|
+
attempts,
|
|
1531
|
+
from,
|
|
1532
|
+
signal: controller.signal,
|
|
1533
|
+
open: async (cursor) => {
|
|
1534
|
+
if (needsRerender) {
|
|
1535
|
+
needsRerender = false;
|
|
1536
|
+
await renderPane(sessionKey);
|
|
1537
|
+
}
|
|
1538
|
+
await openStream(
|
|
1539
|
+
`/api/sessions/${sessionKey}/stream?from=${cursor}`,
|
|
1540
|
+
{ signal: controller.signal },
|
|
1541
|
+
(event, data, id) => {
|
|
1542
|
+
if (id !== null) turn.cursor = Number(id) || turn.cursor;
|
|
1543
|
+
handleChatEvent(event, data, turn);
|
|
1544
|
+
}
|
|
1545
|
+
);
|
|
1546
|
+
return { finished: true, cursor: turn.cursor };
|
|
1547
|
+
},
|
|
1548
|
+
onRetry: ({ attempt, delay }) => {
|
|
1549
|
+
setActivity(turn.active, 'reconnecting');
|
|
1550
|
+
if (!chip) {
|
|
1551
|
+
chip = el('div', 'chip warn', '⟳ reconectando…');
|
|
1552
|
+
turn.assistant().tools.appendChild(chip);
|
|
1553
|
+
}
|
|
1554
|
+
chip.textContent = `⟳ reconectando (intento ${attempt}, en ${Math.round(delay / 1000)} s)…`;
|
|
1555
|
+
},
|
|
1556
|
+
});
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
// Un error del handler (no del transporte) no reintenta: es un bug de la
|
|
1559
|
+
// consola y dejarlo pasar deja un "reconectando" que no dice nada.
|
|
1560
|
+
if (error?.eventHandler) {
|
|
1561
|
+
setActivity(turn.active, null);
|
|
1562
|
+
toast(`Error de la consola al procesar el stream: ${error.message}`);
|
|
1563
|
+
}
|
|
1564
|
+
throw error;
|
|
1565
|
+
} finally {
|
|
1566
|
+
controllers.delete(sessionKey);
|
|
1567
|
+
if (chip) chip.remove();
|
|
1568
|
+
if (state.panes.get(sessionKey) === record) setWorking(sessionKey, false);
|
|
1569
|
+
setActivity(turn.active, null);
|
|
1570
|
+
renderAlertSignals();
|
|
1571
|
+
reconcile({ immediate: true });
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Se engancha a un run en curso: re-renderiza el historial persistido y
|
|
1577
|
+
* reproduce los eventos del turno actual (asi el progreso no se pierde si el
|
|
1578
|
+
* telefono bloqueo la pantalla o se corto la red).
|
|
1579
|
+
*/
|
|
1580
|
+
export async function attachRun(sessionKey, pane) {
|
|
1581
|
+
const record = state.panes.get(sessionKey);
|
|
1582
|
+
if (record?.working) return;
|
|
1583
|
+
// `reconcile` puede pedirlo dos veces seguidas (sweep + canal global).
|
|
1584
|
+
if (controllers.has(sessionKey)) return;
|
|
1585
|
+
const turn = createChatTurn(pane, sessionKey);
|
|
1586
|
+
await followRunSession(sessionKey, turn, { from: 0, rerender: true });
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
export function contextTokensForRequest() {
|
|
1590
|
+
const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
|
|
1591
|
+
return context ? Math.floor(context * 0.8) : undefined;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
export async function startTurn(sessionKey, pane, input, images, opts = {}) {
|
|
1595
|
+
const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
|
|
1596
|
+
appendImages(user, images);
|
|
1597
|
+
const turn = createChatTurn(pane, sessionKey);
|
|
1598
|
+
// Lo dibujamos nosotros, asi que el evento `user` del server no lo repite.
|
|
1599
|
+
turn.sawUser = true;
|
|
1600
|
+
turn.live = true;
|
|
1601
|
+
turn.active = addMessage('assistant', '', pane);
|
|
1602
|
+
setMessageMode(turn.active, turn.turnMode);
|
|
1603
|
+
const controller = new AbortController();
|
|
1604
|
+
controllers.set(sessionKey, controller);
|
|
1605
|
+
setWorking(sessionKey, true);
|
|
1606
|
+
|
|
1607
|
+
// El tipo de agente lo manda la sesion (no los controles globales): asi dos
|
|
1608
|
+
// pestañas con modelos distintos no se pisan.
|
|
1609
|
+
const owner = state.panes.get(sessionKey);
|
|
1610
|
+
try {
|
|
1611
|
+
await streamPost(
|
|
1612
|
+
'/api/chat',
|
|
1613
|
+
{
|
|
1614
|
+
input: input || '(imagen adjunta)',
|
|
1615
|
+
images: images.length ? images : undefined,
|
|
1616
|
+
sessionId: sessionKey,
|
|
1617
|
+
provider: owner?.provider || providerSelect.value || undefined,
|
|
1618
|
+
model: owner?.model || modelSelect.value || undefined,
|
|
1619
|
+
mode: owner?.mode || modeSelect.value || undefined,
|
|
1620
|
+
yes: yesInput.checked ? true : undefined,
|
|
1621
|
+
contextTokens: contextTokensForRequest(),
|
|
1622
|
+
interrupt: opts.interrupt ? true : undefined,
|
|
1623
|
+
},
|
|
1624
|
+
(event, data, id) => {
|
|
1625
|
+
if (id !== null) turn.cursor = Number(id) || turn.cursor;
|
|
1626
|
+
handleChatEvent(event, data, turn);
|
|
1627
|
+
},
|
|
1628
|
+
controller.signal
|
|
1629
|
+
);
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
if (error.name === 'AbortError') {
|
|
1632
|
+
setActivity(turn.active, null);
|
|
1633
|
+
setText(turn.assistant(), '⏹ cancelado.');
|
|
1634
|
+
} else if (error.status === 409) {
|
|
1635
|
+
// Otro cliente (celular, Telegram) tiene el turno: este mensaje no se
|
|
1636
|
+
// guardo, asi que sacamos lo que se dibujo y va a la cola visible.
|
|
1637
|
+
const bubble = turn.active;
|
|
1638
|
+
turn.active = null;
|
|
1639
|
+
user.wrap.remove();
|
|
1640
|
+
bubble?.wrap.remove();
|
|
1641
|
+
enqueueMessage(sessionKey, input, images);
|
|
1642
|
+
toast('La sesión ya tiene una respuesta en curso; tu mensaje quedó en la cola.');
|
|
1643
|
+
} else {
|
|
1644
|
+
// El POST se cayo pero el run puede seguir vivo: seguimos el turno con el
|
|
1645
|
+
// mismo `turn` para no perder las tools ya dibujadas.
|
|
1646
|
+
setActivity(turn.active, 'reconnecting');
|
|
1647
|
+
await followRunSession(sessionKey, turn, { from: turn.cursor, rerender: false });
|
|
1648
|
+
}
|
|
1649
|
+
} finally {
|
|
1650
|
+
if (controllers.get(sessionKey) === controller) controllers.delete(sessionKey);
|
|
1651
|
+
setActivity(turn.active, null);
|
|
1652
|
+
setWorking(sessionKey, false);
|
|
1653
|
+
renderAlertSignals();
|
|
1654
|
+
drainQueue(sessionKey);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/* --- cola de mensajes (enviar mientras el agente trabaja) ----------------- */
|
|
1659
|
+
|
|
1660
|
+
export function queueFor(sessionKey, create = false) {
|
|
1661
|
+
if (!state.queues.has(sessionKey)) {
|
|
1662
|
+
if (!create) return [];
|
|
1663
|
+
state.queues.set(sessionKey, []);
|
|
1664
|
+
}
|
|
1665
|
+
return state.queues.get(sessionKey);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
export function enqueueMessage(sessionKey, input, images) {
|
|
1669
|
+
queueFor(sessionKey, true).push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, input, images });
|
|
1670
|
+
renderQueue(sessionKey);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
export function renderQueue(sessionKey = state.sessionId) {
|
|
1674
|
+
const queueEl = $('#chat-queue');
|
|
1675
|
+
if (!queueEl || sessionKey !== state.sessionId) return;
|
|
1676
|
+
clear(queueEl);
|
|
1677
|
+
const items = queueFor(sessionKey);
|
|
1678
|
+
queueEl.hidden = !items.length;
|
|
1679
|
+
if (!items.length) return;
|
|
1680
|
+
queueEl.appendChild(el('div', 'queue-label', `en cola (${items.length})`));
|
|
1681
|
+
for (const item of items) {
|
|
1682
|
+
const row = el('div', 'queue-item');
|
|
1683
|
+
const text = item.input || `(${item.images.length} imagen${item.images.length > 1 ? 'es' : ''})`;
|
|
1684
|
+
row.append(el('span', 'queue-text', text));
|
|
1685
|
+
row.appendChild(
|
|
1686
|
+
button('✕', () => {
|
|
1687
|
+
const list = queueFor(sessionKey);
|
|
1688
|
+
const index = list.findIndex((entry) => entry.id === item.id);
|
|
1689
|
+
if (index >= 0) list.splice(index, 1);
|
|
1690
|
+
renderQueue(sessionKey);
|
|
1691
|
+
}, 'queue-remove')
|
|
1692
|
+
);
|
|
1693
|
+
queueEl.appendChild(row);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
export function drainQueue(sessionKey) {
|
|
1698
|
+
const record = state.panes.get(sessionKey);
|
|
1699
|
+
if (!record || record.working) return;
|
|
1700
|
+
// Mientras se interrumpe, el mensaje que manda el usuario lo arranca
|
|
1701
|
+
// `interruptAndSend`, no la cola.
|
|
1702
|
+
if (interrupting.has(sessionKey)) return;
|
|
1703
|
+
const list = queueFor(sessionKey);
|
|
1704
|
+
if (!list.length) return;
|
|
1705
|
+
const next = list.shift();
|
|
1706
|
+
renderQueue(sessionKey);
|
|
1707
|
+
startTurn(sessionKey, record.el, next.input, next.images);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/* --- shell directo con "!" ------------------------------------------------ */
|
|
1711
|
+
|
|
1712
|
+
export async function runShell(command) {
|
|
1713
|
+
const record = state.panes.get(state.sessionId);
|
|
1714
|
+
const pane = activePane ?? record?.el;
|
|
1715
|
+
if (!pane) return;
|
|
1716
|
+
addMessage('user', `! ${command}`, pane);
|
|
1717
|
+
const message = addMessage('assistant', '', pane);
|
|
1718
|
+
message.textEl.appendChild(el('pre', 'shell-out', 'ejecutando…'));
|
|
1719
|
+
const data = await api('/api/shell', {
|
|
1720
|
+
method: 'POST',
|
|
1721
|
+
body: { command, cwd: record?.cwd || state.cwd || undefined },
|
|
1722
|
+
}).catch((error) => ({ error: error.message }));
|
|
1723
|
+
if (data?.error) {
|
|
1724
|
+
message.textEl.textContent = '';
|
|
1725
|
+
message.textEl.classList.add('notice', 'err');
|
|
1726
|
+
message.textEl.textContent = data.error;
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
message.textEl.textContent = '';
|
|
1730
|
+
const pre = el('pre', 'shell-out');
|
|
1731
|
+
pre.textContent = (data.output || '').trim() || '(sin salida)';
|
|
1732
|
+
message.textEl.appendChild(pre);
|
|
1733
|
+
scheduleScrollNav();
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
export async function sendMessage() {
|
|
1737
|
+
const current = state.panes.get(state.sessionId);
|
|
1738
|
+
hideSlashMenu();
|
|
1739
|
+
let input = chatInput.value.trim();
|
|
1740
|
+
const images = state.attachments.slice();
|
|
1741
|
+
|
|
1742
|
+
// "!" ejecuta un comando de shell directo, sin pasar por el modelo.
|
|
1743
|
+
if (input.startsWith('!')) {
|
|
1744
|
+
const command = input.slice(1).trim();
|
|
1745
|
+
chatInput.value = '';
|
|
1746
|
+
state.attachments = [];
|
|
1747
|
+
renderAttachments();
|
|
1748
|
+
if (command) runShell(command);
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
if (input.startsWith('/') && !images.length) {
|
|
1753
|
+
const name = input.slice(1).split(/\s+/)[0].toLowerCase();
|
|
1754
|
+
const arg = input.slice(1 + name.length).trim();
|
|
1755
|
+
// Limpiamos el input de una: hay comandos lentos (compactar, resumir) y no
|
|
1756
|
+
// queremos dejar el comando escrito mientras corren.
|
|
1757
|
+
chatInput.value = '';
|
|
1758
|
+
onChatInputChanged();
|
|
1759
|
+
const outcome = await runCommand(name, arg);
|
|
1760
|
+
if (!outcome || outcome.handled) return;
|
|
1761
|
+
input = outcome.input ?? input;
|
|
1762
|
+
}
|
|
1763
|
+
if (!input && !images.length) return;
|
|
1764
|
+
if (!state.sessionId) {
|
|
1765
|
+
state.pendingInput = input;
|
|
1766
|
+
openFolderPicker();
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Si el agente está trabajando, el mensaje queda en cola y se envía solo.
|
|
1771
|
+
// Para cortarlo y mandar el mensaje ya, está el botón/atajo de interrumpir.
|
|
1772
|
+
if (current?.working) {
|
|
1773
|
+
enqueueMessage(state.sessionId, input, images);
|
|
1774
|
+
chatInput.value = '';
|
|
1775
|
+
state.attachments = [];
|
|
1776
|
+
renderAttachments();
|
|
1777
|
+
syncInterruptBtn();
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
if (state.pendingEdit) {
|
|
1782
|
+
await api(`/api/sessions/${state.sessionId}/truncate`, {
|
|
1783
|
+
method: 'POST',
|
|
1784
|
+
body: { fromId: state.pendingEdit.fromId },
|
|
1785
|
+
}).catch(() => {});
|
|
1786
|
+
await reloadPane(state.sessionId);
|
|
1787
|
+
state.pendingEdit = null;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
chatInput.value = '';
|
|
1791
|
+
state.attachments = [];
|
|
1792
|
+
renderAttachments();
|
|
1793
|
+
await startTurn(state.sessionId, activePane, input, images);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* Interrumpe el turno en curso y manda `input`/`images` ya mismo.
|
|
1798
|
+
*
|
|
1799
|
+
* Marcamos la sesion en `interrupting` para que el `finally` del turno viejo no
|
|
1800
|
+
* dispare la cola (lo arrancamos nosotros). Esperamos a que el turno anterior se
|
|
1801
|
+
* desenrolle antes de empezar el nuevo; el server, por su lado, cancela el run y
|
|
1802
|
+
* espera a que termine antes de aceptar el POST con `interrupt: true`.
|
|
1803
|
+
*/
|
|
1804
|
+
export async function interruptAndSend(sessionKey, input, images) {
|
|
1805
|
+
const record = state.panes.get(sessionKey);
|
|
1806
|
+
if (!record) return;
|
|
1807
|
+
interrupting.add(sessionKey);
|
|
1808
|
+
controllers.get(sessionKey)?.abort();
|
|
1809
|
+
await whenIdle(sessionKey);
|
|
1810
|
+
const promise = startTurn(sessionKey, record.el, input, images, { interrupt: true });
|
|
1811
|
+
// startTurn ya arranco (working=true y controller nuevo); soltamos el guardia
|
|
1812
|
+
// para que SU finally pueda drenar la cola.
|
|
1813
|
+
interrupting.delete(sessionKey);
|
|
1814
|
+
await promise;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
export function sendInterrupt() {
|
|
1818
|
+
const record = state.panes.get(state.sessionId);
|
|
1819
|
+
if (!record?.working) return;
|
|
1820
|
+
const input = chatInput.value.trim();
|
|
1821
|
+
// Los comandos (`/…`, `!…`) se resuelven por su camino normal, no interrumpen.
|
|
1822
|
+
if (input.startsWith('/') || input.startsWith('!')) return sendMessage();
|
|
1823
|
+
const images = state.attachments.slice();
|
|
1824
|
+
if (!input && !images.length) return;
|
|
1825
|
+
chatInput.value = '';
|
|
1826
|
+
state.attachments = [];
|
|
1827
|
+
renderAttachments();
|
|
1828
|
+
syncInterruptBtn();
|
|
1829
|
+
interruptAndSend(state.sessionId, input, images);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/* --- image attachments ---------------------------------------------------- */
|
|
1833
|
+
|
|
1834
|
+
export const attachInput = $('#image-input');
|
|
1835
|
+
export const attachPreview = $('#attach-preview');
|
|
1836
|
+
|
|
1837
|
+
export function renderAttachments() {
|
|
1838
|
+
clear(attachPreview);
|
|
1839
|
+
attachPreview.hidden = !state.attachments.length;
|
|
1840
|
+
state.attachments.forEach((url, index) => {
|
|
1841
|
+
const thumb = el('div', 'attach-thumb');
|
|
1842
|
+
const img = el('img');
|
|
1843
|
+
img.src = url;
|
|
1844
|
+
img.alt = `adjunto ${index + 1}`;
|
|
1845
|
+
const remove = button('', () => {
|
|
1846
|
+
state.attachments.splice(index, 1);
|
|
1847
|
+
renderAttachments();
|
|
1848
|
+
}, 'attach-remove');
|
|
1849
|
+
remove.appendChild(icon('x', 12));
|
|
1850
|
+
thumb.append(img, remove);
|
|
1851
|
+
attachPreview.appendChild(thumb);
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
export function readAsDataUrl(file) {
|
|
1856
|
+
return new Promise((resolve, reject) => {
|
|
1857
|
+
const reader = new FileReader();
|
|
1858
|
+
reader.onerror = () => reject(new Error('No se pudo leer la imagen'));
|
|
1859
|
+
reader.onload = () => resolve(reader.result);
|
|
1860
|
+
reader.readAsDataURL(file);
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
export async function fileToDataUrl(file) {
|
|
1865
|
+
if (file.type === 'image/gif' || file.size <= 256 * 1024) return readAsDataUrl(file);
|
|
1866
|
+
const url = await readAsDataUrl(file);
|
|
1867
|
+
return new Promise((resolve) => {
|
|
1868
|
+
const img = new Image();
|
|
1869
|
+
img.onerror = () => resolve(url);
|
|
1870
|
+
img.onload = () => {
|
|
1871
|
+
const scale = Math.min(1, 1600 / Math.max(img.width, img.height));
|
|
1872
|
+
const canvas = document.createElement('canvas');
|
|
1873
|
+
canvas.width = Math.max(1, Math.round(img.width * scale));
|
|
1874
|
+
canvas.height = Math.max(1, Math.round(img.height * scale));
|
|
1875
|
+
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1876
|
+
resolve(canvas.toDataURL('image/jpeg', 0.85));
|
|
1877
|
+
};
|
|
1878
|
+
img.src = url;
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
export async function addImageFiles(files) {
|
|
1883
|
+
for (const file of files) {
|
|
1884
|
+
if (!file?.type?.startsWith('image/')) continue;
|
|
1885
|
+
try {
|
|
1886
|
+
state.attachments.push(await fileToDataUrl(file));
|
|
1887
|
+
} catch {
|
|
1888
|
+
// skip unreadable files
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
renderAttachments();
|
|
1892
|
+
}
|
|
1893
|
+
|