@danieltmn/tamnora 0.22.0 → 0.23.1
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 +25 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +9 -1
- package/harness/packages/cli/src/commands/bridge.js +3 -3
- package/harness/packages/cli/src/commands/doctor.js +42 -4
- package/harness/packages/cli/src/commands/repl.js +1 -0
- package/harness/packages/cli/src/commands/run.js +1 -0
- package/harness/packages/cli/src/commands/tui.js +1 -0
- package/harness/packages/cli/src/commands/update.js +13 -2
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/cli/test/bin-warning.test.js +27 -0
- package/harness/packages/cli/test/tunnel.test.js +49 -1
- package/harness/packages/core/src/browser/index.js +27 -13
- package/harness/packages/core/src/index.js +1 -1
- package/harness/packages/core/src/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +32 -9
- package/harness/packages/core/test/agent.test.js +18 -0
- package/harness/packages/core/test/servicios-vivos.test.js +71 -0
- package/harness/packages/core/test/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +9 -1
- package/harness/packages/server/public/app.js +4 -3
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -1893
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +5 -4
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +22 -23
- package/harness/packages/server/public/settings.js +4 -0
- package/harness/packages/server/public/styles.css +20 -50
- package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
- package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
- package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
- package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
- package/harness/packages/server/src/lib/channels/telegram.js +163 -933
- package/harness/packages/server/src/lib/procs.js +4 -1
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +1 -0
- package/harness/packages/server/src/routes/chat.js +1 -1
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/server.js +6 -0
- package/harness/packages/server/test/chatFormat.test.js +119 -0
- package/harness/packages/server/test/consoleModules.test.js +301 -0
- package/harness/packages/server/test/consoleSmoke.test.js +34 -0
- package/harness/packages/server/test/helpers.js +15 -1
- package/harness/packages/server/test/ops.test.js +9 -5
- package/harness/packages/server/test/procs-manager.test.js +17 -0
- package/harness/packages/server/test/settings.test.js +18 -1
- package/harness/scripts/canary.mjs +24 -0
- package/harness/scripts/self-update.mjs +4 -1
- package/package.json +3 -2
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// Tarjetas de la consola: las que dibujan una herramienta, un permiso, una
|
|
2
|
+
// pregunta, un plan o el comentario del asesor.
|
|
3
|
+
|
|
4
|
+
import { api, button, el, icon, state } from './core.js';
|
|
5
|
+
import { toast } from './commands.js';
|
|
6
|
+
import { addMessage, chatInput, paneOf, scrollMessages, sendMessage } from './chat.js';
|
|
7
|
+
import { ADVISOR_KINDS, advisorMeta, isFailedToolText, isReadOnlyTool, screenshotName, toolSummary } from './chatFormat.js';
|
|
8
|
+
import { renderMarkdown } from './markdown.js';
|
|
9
|
+
import { summarizeArgs } from './reconcile.js';
|
|
10
|
+
import { yesInput } from './chrome.js';
|
|
11
|
+
import { reconcile, renderAlertSignals } from './system.js';
|
|
12
|
+
import { reproducirVoz } from './voice.js';
|
|
13
|
+
export function renderToolOut(node, text) {
|
|
14
|
+
const content = String(text ?? '');
|
|
15
|
+
const lines = content.split('\n');
|
|
16
|
+
const adds = lines.filter((line) => line.startsWith('+ ')).length;
|
|
17
|
+
const dels = lines.filter((line) => line.startsWith('- ')).length;
|
|
18
|
+
if (adds === 0 || dels === 0) {
|
|
19
|
+
node.textContent = content;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
node.textContent = '';
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
const cls = line.startsWith('+ ')
|
|
25
|
+
? 'diff-add'
|
|
26
|
+
: line.startsWith('- ')
|
|
27
|
+
? 'diff-del'
|
|
28
|
+
: /^ {2}…/.test(line)
|
|
29
|
+
? 'diff-sep'
|
|
30
|
+
: 'diff-eq';
|
|
31
|
+
node.appendChild(el('span', `diff-line ${cls}`, line));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const toolCardIndex = new Map();
|
|
36
|
+
|
|
37
|
+
/** Muestra la captura del tool `screenshot` en su tarjeta (clic para ampliar). */
|
|
38
|
+
export function showScreenshot(entry, name) {
|
|
39
|
+
if (!entry || !name || entry.shot) return;
|
|
40
|
+
entry.shot = true;
|
|
41
|
+
const img = el('img', 'tool-shot');
|
|
42
|
+
img.loading = 'lazy';
|
|
43
|
+
img.alt = 'captura de pantalla';
|
|
44
|
+
img.src = `/api/screenshots/${encodeURIComponent(name)}`;
|
|
45
|
+
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
|
46
|
+
entry.card.appendChild(img);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Cierra la tarjeta de un tool-call (evento tool_result o mensaje persistido). */
|
|
50
|
+
export function finishToolCard(entry, data) {
|
|
51
|
+
if (!entry) return;
|
|
52
|
+
const preview = data?.preview ?? data?.result ?? '';
|
|
53
|
+
const failed = data?.failed ?? isFailedToolText(preview);
|
|
54
|
+
renderToolOut(entry.out, preview);
|
|
55
|
+
entry.card.classList.remove('running');
|
|
56
|
+
entry.card.classList.toggle('failed', failed);
|
|
57
|
+
if (!failed) entry.card.classList.add('done');
|
|
58
|
+
entry.glyph.textContent = failed ? '✗' : '✓';
|
|
59
|
+
if (data?.truncated) {
|
|
60
|
+
entry.truncated = true;
|
|
61
|
+
entry.more.hidden = false;
|
|
62
|
+
}
|
|
63
|
+
if (!failed) showScreenshot(entry, screenshotName(data, preview));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ensureToolsToggle(message) {
|
|
67
|
+
if (!message.toolsToggle) {
|
|
68
|
+
const bar = el('div', 'tools-actions');
|
|
69
|
+
const label = el('span', 'tools-count');
|
|
70
|
+
const toggle = button('colapsar todo', () => {
|
|
71
|
+
const details = [...message.tools.querySelectorAll('details.tool-card')];
|
|
72
|
+
const anyOpen = details.some((node) => node.open);
|
|
73
|
+
for (const node of details) node.open = !anyOpen;
|
|
74
|
+
toggle.textContent = anyOpen ? 'expandir todo' : 'colapsar todo';
|
|
75
|
+
}, 'tools-toggle');
|
|
76
|
+
bar.append(label, toggle);
|
|
77
|
+
message.tools.prepend(bar);
|
|
78
|
+
message.toolsToggle = bar;
|
|
79
|
+
message.toolsCount = label;
|
|
80
|
+
message.toolsToggleBtn = toggle;
|
|
81
|
+
}
|
|
82
|
+
const total = message.tools.querySelectorAll('details.tool-card').length;
|
|
83
|
+
const groups = new Set([...message.tools.querySelectorAll('details.tool-card')].map((n) => n.dataset.round ?? ''));
|
|
84
|
+
message.toolsToggle.hidden = total < 2;
|
|
85
|
+
message.toolsCount.textContent = groups.size > 1 ? `${total} pasos` : `${total} herramientas`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function loadFullToolResult(entry, message) {
|
|
89
|
+
if (entry.full || !entry.callId) return;
|
|
90
|
+
const sessionId = paneOf(message)?.dataset.session;
|
|
91
|
+
if (!sessionId) return;
|
|
92
|
+
entry.more.disabled = true;
|
|
93
|
+
entry.more.textContent = 'cargando…';
|
|
94
|
+
const data = await api(
|
|
95
|
+
`/api/sessions/${sessionId}/tool-result?callId=${encodeURIComponent(entry.callId)}`
|
|
96
|
+
).catch(() => null);
|
|
97
|
+
entry.more.disabled = false;
|
|
98
|
+
if (data?.content != null) {
|
|
99
|
+
renderToolOut(entry.out, data.content);
|
|
100
|
+
entry.full = true;
|
|
101
|
+
entry.more.hidden = true;
|
|
102
|
+
} else {
|
|
103
|
+
entry.more.textContent = 'ver completo';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Botón del tool `speak`: encola el audio pedido en el reproductor global. */
|
|
108
|
+
export function speakControl(args) {
|
|
109
|
+
const texto = String(args?.texto ?? '').trim();
|
|
110
|
+
if (!texto) return null;
|
|
111
|
+
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, pitch: args?.pitch, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
112
|
+
btn.append(icon('play', 13), el('span', '', 'escuchar'));
|
|
113
|
+
return btn;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Texto de la tarjeta: el server ya manda `summary` calculado; esto es el fallback. */
|
|
117
|
+
export function addToolCard(message, name, args, { callId = null, open = null, round = null, summary = null } = {}) {
|
|
118
|
+
const collapsed = open === null ? isReadOnlyTool(name) : !open;
|
|
119
|
+
const card = el('details', 'tool-card running');
|
|
120
|
+
card.open = !collapsed;
|
|
121
|
+
if (round != null) card.dataset.round = String(round);
|
|
122
|
+
const head = el('summary', 'tool-head');
|
|
123
|
+
const glyph = el('span', 'tool-glyph', '⏺');
|
|
124
|
+
const argsEl = el('span', 'tool-args', summary ?? toolSummary(name, args));
|
|
125
|
+
head.append(glyph, el('span', 'tool-name', name), argsEl);
|
|
126
|
+
const out = el('div', 'tool-out');
|
|
127
|
+
const speak = name === 'speak' ? speakControl(args) : null;
|
|
128
|
+
const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
|
|
129
|
+
more.hidden = true;
|
|
130
|
+
card.append(head, out);
|
|
131
|
+
if (speak) card.appendChild(speak);
|
|
132
|
+
card.appendChild(more);
|
|
133
|
+
message.tools.appendChild(card);
|
|
134
|
+
ensureToolsToggle(message);
|
|
135
|
+
scrollMessages(paneOf(message));
|
|
136
|
+
|
|
137
|
+
const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
|
|
138
|
+
card.addEventListener('toggle', () => {
|
|
139
|
+
if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
|
|
140
|
+
});
|
|
141
|
+
return entry;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* La tarjeta de un permiso que sigue en el DOM, o null si ya no esta (la
|
|
146
|
+
* borraron, p. ej. un re-render del pane). Evita dibujar dos tarjetas por el
|
|
147
|
+
* mismo permiso sin dejar entradas huerfanas.
|
|
148
|
+
*/
|
|
149
|
+
export function livePermission(askId) {
|
|
150
|
+
const entry = state.permissionCards.get(askId);
|
|
151
|
+
return entry && entry.card.isConnected ? entry : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function addPermissionCard(message, data) {
|
|
155
|
+
// Llega por el stream de la sesion y por el canal global: sin esta guarda se
|
|
156
|
+
// dibujan dos tarjetas por el mismo permiso. Pero `renderPane` borra el pane,
|
|
157
|
+
// asi que si la tarjeta vieja quedo desconectada hay que rehacerla en vez de
|
|
158
|
+
// dejar el mapa apuntando a algo que ya no esta en el DOM.
|
|
159
|
+
const previous = state.permissionCards.get(data.askId);
|
|
160
|
+
if (previous && previous.card.isConnected) return previous.card;
|
|
161
|
+
const card = el('div', 'perm-card');
|
|
162
|
+
const head = el('div', 'perm-head');
|
|
163
|
+
head.append(
|
|
164
|
+
el('span', 'perm-icon', '⚠'),
|
|
165
|
+
el('span', 'perm-title', `permiso para ${data.name}${repeatedToolNotice(data.name)}`)
|
|
166
|
+
);
|
|
167
|
+
const args = el('div', 'perm-args', summarizeArgs(data.name, data.args, 300));
|
|
168
|
+
const actions = el('div', 'perm-actions');
|
|
169
|
+
const verdict = el('span', 'muted', 'requiere aprobación');
|
|
170
|
+
const buttons = {
|
|
171
|
+
deny: button('denegar', () => decide(false, 'once'), 'btn'),
|
|
172
|
+
once: button('permitir una vez', () => decide(true, 'once'), 'btn primary'),
|
|
173
|
+
always: button('autorizar todo', () => decide(true, 'session'), 'btn'),
|
|
174
|
+
};
|
|
175
|
+
actions.append(verdict, el('span', 'spacer'), buttons.deny, buttons.once, buttons.always);
|
|
176
|
+
card.append(head, args, actions);
|
|
177
|
+
// Una vez resuelta queda minima, pero se puede reabrir para leer que se pidio.
|
|
178
|
+
// Sin esto la tarjeta es info muerta: nunca se puede volver a desplegar.
|
|
179
|
+
head.addEventListener('click', () => {
|
|
180
|
+
if (!card.classList.contains('approved') && !card.classList.contains('denied')) return;
|
|
181
|
+
card.classList.toggle('collapsed');
|
|
182
|
+
});
|
|
183
|
+
message.tools.appendChild(card);
|
|
184
|
+
scrollMessages(paneOf(message));
|
|
185
|
+
state.permissionCards.set(data.askId, { card, verdict, buttons, message });
|
|
186
|
+
|
|
187
|
+
async function decide(allow, scope) {
|
|
188
|
+
for (const btn of Object.values(buttons)) btn.disabled = true;
|
|
189
|
+
card.classList.remove('pending');
|
|
190
|
+
card.classList.add('collapsed', allow ? 'approved' : 'denied');
|
|
191
|
+
card.querySelector('.perm-icon').textContent = allow ? '✓' : '✗';
|
|
192
|
+
verdict.textContent =
|
|
193
|
+
allow && scope === 'session' ? 'autorizado para toda la sesión' : allow ? 'aprobado' : 'denegado';
|
|
194
|
+
if (allow && scope === 'session') yesInput.checked = true;
|
|
195
|
+
try {
|
|
196
|
+
await api(`/api/permissions/${encodeURIComponent(data.askId)}`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
body: { allow, scope },
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// Esto se tragaba el error. La UI se cerraba igual, se borraba el permiso
|
|
202
|
+
// local y el siguiente reconcile lo volvia a traer porque el server
|
|
203
|
+
// seguia tenerlo pendiente: el permiso reaparecia una y otra vez sin
|
|
204
|
+
// decir por que.
|
|
205
|
+
if (error?.status === 404) {
|
|
206
|
+
// El server ya no tiene ese permiso (expiro o lo resolvio otra
|
|
207
|
+
// ventana): la tarjeta era vieja, no hay nada que decidir.
|
|
208
|
+
card.classList.remove('pending');
|
|
209
|
+
card.classList.add('collapsed', 'denied');
|
|
210
|
+
verdict.textContent = 'ya no aplica';
|
|
211
|
+
state.pendingAsks.delete(data.askId);
|
|
212
|
+
toast('Ese permiso ya no estaba esperando decisión.');
|
|
213
|
+
reconcile({ immediate: true });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const btn of Object.values(buttons)) btn.disabled = false;
|
|
217
|
+
card.classList.remove('approved', 'denied');
|
|
218
|
+
card.classList.add('pending');
|
|
219
|
+
card.querySelector('.perm-icon').textContent = '⚠';
|
|
220
|
+
verdict.textContent = 'requiere aprobación';
|
|
221
|
+
toast(`No se pudo resolver el permiso: ${error.message}`);
|
|
222
|
+
reconcile({ immediate: true });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
rememberDecision(data.name);
|
|
226
|
+
// El permiso dejo de existir en el server: que el proximo snapshot no lo
|
|
227
|
+
// vuelva a marcar como pendiente ni lo vuelva a pintar.
|
|
228
|
+
state.pendingAsks.delete(data.askId);
|
|
229
|
+
reconcile({ immediate: true });
|
|
230
|
+
}
|
|
231
|
+
card.classList.add('pending');
|
|
232
|
+
return card;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function rememberDecision(name) {
|
|
236
|
+
state.recentDecisions.set(name, Date.now());
|
|
237
|
+
// No crecer sin limite: solo interesa "lo acabo de aprobar".
|
|
238
|
+
for (const [tool, at] of state.recentDecisions) {
|
|
239
|
+
if (Date.now() - at > 120000) state.recentDecisions.delete(tool);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Si la misma tool vuelve a pedir permiso segundos despues de que la
|
|
245
|
+
* aprobaste, es una peticion **nueva** (otro `askId`), no la misma que vuelve:
|
|
246
|
+
* la tarjeta lo dice, asi que el agente reintentando no parece un bug.
|
|
247
|
+
*/
|
|
248
|
+
export function repeatedToolNotice(name) {
|
|
249
|
+
const at = state.recentDecisions.get(name);
|
|
250
|
+
if (!at) return '';
|
|
251
|
+
const seconds = Math.round((Date.now() - at) / 1000);
|
|
252
|
+
return seconds < 120 ? ` · lo pidió de nuevo (aprobaste hace ${seconds} s)` : '';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Resuelve una tarjeta desde aca (banner o `permission_resolved` de otro). */
|
|
256
|
+
export function resolvePermissionCard(entry, allow) {
|
|
257
|
+
if (!entry) return;
|
|
258
|
+
for (const btn of Object.values(entry.buttons)) btn.disabled = true;
|
|
259
|
+
entry.card.classList.remove('pending');
|
|
260
|
+
if (allow === null) return;
|
|
261
|
+
entry.card.classList.add('collapsed', allow ? 'approved' : 'denied');
|
|
262
|
+
entry.card.querySelector('.perm-icon').textContent = allow ? '✓' : '✗';
|
|
263
|
+
entry.verdict.textContent = allow ? 'aprobado desde otra ventana' : 'denegado desde otra ventana';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* --- formularios interactivos (ask_user) ---------------------------------- */
|
|
267
|
+
|
|
268
|
+
export function liveQuestion(askId) {
|
|
269
|
+
const entry = state.questionCards.get(askId);
|
|
270
|
+
return entry && entry.card.isConnected ? entry : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Formulario que el modelo pide con `ask_user`: en vez de preguntas numeradas en
|
|
275
|
+
* el texto, la consola renderiza los campos y el usuario los contesta de una.
|
|
276
|
+
*/
|
|
277
|
+
export function addQuestionCard(container, data) {
|
|
278
|
+
const previous = state.questionCards.get(data.askId);
|
|
279
|
+
if (previous && previous.card.isConnected) return previous.card;
|
|
280
|
+
const card = el('div', 'question-card');
|
|
281
|
+
if (data.title) card.appendChild(el('div', 'question-title', data.title));
|
|
282
|
+
const form = el('form', 'question-form');
|
|
283
|
+
const fields = [];
|
|
284
|
+
for (const q of data.questions ?? []) {
|
|
285
|
+
const field = el('label', 'question-field');
|
|
286
|
+
field.appendChild(el('span', 'question-label', q.label));
|
|
287
|
+
let input;
|
|
288
|
+
if (q.type === 'textarea') {
|
|
289
|
+
input = document.createElement('textarea');
|
|
290
|
+
input.rows = 3;
|
|
291
|
+
} else if (q.type === 'select' || q.type === 'confirm') {
|
|
292
|
+
input = document.createElement('select');
|
|
293
|
+
const options = q.type === 'confirm' ? ['sí', 'no'] : q.options ?? [];
|
|
294
|
+
for (const opt of options) {
|
|
295
|
+
const option = document.createElement('option');
|
|
296
|
+
option.value = opt;
|
|
297
|
+
option.textContent = opt;
|
|
298
|
+
input.appendChild(option);
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
input = document.createElement('input');
|
|
302
|
+
input.type = 'text';
|
|
303
|
+
}
|
|
304
|
+
if (q.placeholder && 'placeholder' in input) input.placeholder = q.placeholder;
|
|
305
|
+
input.name = q.id;
|
|
306
|
+
input.className = 'question-input';
|
|
307
|
+
field.appendChild(input);
|
|
308
|
+
form.appendChild(field);
|
|
309
|
+
fields.push({ q, input });
|
|
310
|
+
}
|
|
311
|
+
const actions = el('div', 'question-actions');
|
|
312
|
+
const status = el('span', 'muted', '');
|
|
313
|
+
const submit = button('enviar', () => {});
|
|
314
|
+
submit.type = 'submit';
|
|
315
|
+
submit.classList.add('btn', 'primary');
|
|
316
|
+
actions.append(status, el('span', 'spacer'), submit);
|
|
317
|
+
form.appendChild(actions);
|
|
318
|
+
card.appendChild(form);
|
|
319
|
+
(container.tools ?? container).appendChild(card);
|
|
320
|
+
scrollMessages(paneOf(container));
|
|
321
|
+
state.questionCards.set(data.askId, { card, fields, status });
|
|
322
|
+
|
|
323
|
+
const settle = (text, klass) => {
|
|
324
|
+
card.classList.add(klass);
|
|
325
|
+
status.textContent = text;
|
|
326
|
+
submit.disabled = true;
|
|
327
|
+
for (const { input } of fields) input.disabled = true;
|
|
328
|
+
state.pendingQuestions.delete(data.askId);
|
|
329
|
+
renderAlertSignals();
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
form.addEventListener('submit', async (event) => {
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
const answers = {};
|
|
335
|
+
for (const { q, input } of fields) {
|
|
336
|
+
answers[q.id] = q.type === 'confirm' ? input.value === 'sí' : input.value;
|
|
337
|
+
}
|
|
338
|
+
submit.disabled = true;
|
|
339
|
+
try {
|
|
340
|
+
await api(`/api/questions/${encodeURIComponent(data.askId)}`, { method: 'POST', body: { answers } });
|
|
341
|
+
settle('enviado', 'answered');
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (error?.status === 404) {
|
|
344
|
+
settle('ya no aplica', 'answered');
|
|
345
|
+
reconcile({ immediate: true });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
submit.disabled = false;
|
|
349
|
+
status.textContent = `no se pudo enviar: ${error.message}`;
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return card;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Formulario pendiente recreado tras un reload (no se reconstruye de mensajes). */
|
|
356
|
+
export function showStandaloneQuestion(record, ask) {
|
|
357
|
+
if (liveQuestion(ask.askId)) return;
|
|
358
|
+
record.el.querySelector('.empty')?.remove();
|
|
359
|
+
const holder = addMessage('assistant', '', record.el);
|
|
360
|
+
holder.textEl.remove();
|
|
361
|
+
addQuestionCard(holder, { askId: ask.askId, title: ask.title, questions: ask.questions });
|
|
362
|
+
state.pendingQuestions.set(ask.askId, { sessionId: record.id });
|
|
363
|
+
renderAlertSignals();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* --- botones de opcion (offer_choices) ------------------------------------ */
|
|
367
|
+
|
|
368
|
+
export function sendChoice(label) {
|
|
369
|
+
chatInput.value = label;
|
|
370
|
+
sendMessage();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Botones tappables: al tocar uno se manda como mensaje del usuario. */
|
|
374
|
+
export function addChoiceCard(container, { question, options } = {}) {
|
|
375
|
+
const items = Array.isArray(options) ? options.map((o) => String(o).trim()).filter(Boolean).slice(0, 6) : [];
|
|
376
|
+
if (!items.length || !container) return null;
|
|
377
|
+
const card = el('div', 'choice-card');
|
|
378
|
+
if (question) card.appendChild(el('div', 'choice-question', question));
|
|
379
|
+
const row = el('div', 'choice-options');
|
|
380
|
+
for (const label of items) row.appendChild(button(label, () => sendChoice(label), 'btn choice-btn'));
|
|
381
|
+
card.appendChild(row);
|
|
382
|
+
(container.tools ?? container).appendChild(card);
|
|
383
|
+
scrollMessages(paneOf(container));
|
|
384
|
+
return card;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* --- plan/checklist (show_plan) ------------------------------------------- */
|
|
388
|
+
|
|
389
|
+
export const PLAN_MARKS = { done: '✓', active: '◐', pending: '○' };
|
|
390
|
+
|
|
391
|
+
/** Checklist interactivo: el modelo lo reemite para actualizar el estado. */
|
|
392
|
+
export function addPlanCard(container, { title, steps } = {}) {
|
|
393
|
+
const items = Array.isArray(steps)
|
|
394
|
+
? steps.filter((s) => s && String(s.text ?? '').trim()).slice(0, 20)
|
|
395
|
+
: [];
|
|
396
|
+
if (!items.length || !container) return null;
|
|
397
|
+
const card = el('div', 'plan-card');
|
|
398
|
+
if (title) card.appendChild(el('div', 'plan-title', String(title)));
|
|
399
|
+
const list = el('ul', 'plan-steps');
|
|
400
|
+
for (const step of items) {
|
|
401
|
+
const status = ['done', 'active', 'pending'].includes(step.status) ? step.status : 'pending';
|
|
402
|
+
const li = el('li', `plan-step ${status}`);
|
|
403
|
+
li.append(el('span', 'plan-mark', PLAN_MARKS[status]), el('span', 'plan-text', String(step.text)));
|
|
404
|
+
list.appendChild(li);
|
|
405
|
+
}
|
|
406
|
+
card.appendChild(list);
|
|
407
|
+
(container.tools ?? container).appendChild(card);
|
|
408
|
+
scrollMessages(paneOf(container));
|
|
409
|
+
return card;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function addAdvisorCard(container, { text, kind } = {}) {
|
|
413
|
+
const clean = String(text ?? '').trim();
|
|
414
|
+
if (!clean || !container) return null;
|
|
415
|
+
const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
|
|
416
|
+
if (exists) return null;
|
|
417
|
+
const meta = advisorMeta(kind);
|
|
418
|
+
const klass = ADVISOR_KINDS[kind] ? ` advisor-${kind}` : '';
|
|
419
|
+
const card = el('div', `advisor-card${klass}`);
|
|
420
|
+
const head = el('div', 'advisor-head');
|
|
421
|
+
head.append(
|
|
422
|
+
el('span', 'advisor-glyph', meta.glyph),
|
|
423
|
+
el('span', 'advisor-title', 'asesor'),
|
|
424
|
+
el('span', `advisor-badge advisor-badge-${kind || 'advice'}`, meta.label)
|
|
425
|
+
);
|
|
426
|
+
card.append(head, el('div', 'advisor-text', clean));
|
|
427
|
+
container.appendChild(card);
|
|
428
|
+
return card;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Marca del punto de compactación: una línea que corta el historial y, debajo,
|
|
433
|
+
* el resumen que pasa a ser el contexto del modelo. Lo de arriba sigue visible
|
|
434
|
+
* como histórico; al modelo solo le llega lo de abajo (más los mensajes nuevos).
|
|
435
|
+
*/
|
|
436
|
+
export function addCompactCard(container, { text, label, running = false } = {}) {
|
|
437
|
+
if (!container) return null;
|
|
438
|
+
const card = el('div', `compact-card${running ? ' running' : ''}`);
|
|
439
|
+
const rule = el('div', 'compact-rule', label ?? (running ? 'compactando…' : 'contexto compactado'));
|
|
440
|
+
card.append(rule);
|
|
441
|
+
if (running) {
|
|
442
|
+
// La compactación puede tardar (el modelo resume el historial): mostramos el
|
|
443
|
+
// corte con un loader animado hasta que el resumen llegue.
|
|
444
|
+
rule.append(el('span', 'loader loader-thinking'));
|
|
445
|
+
} else {
|
|
446
|
+
const clean = String(text ?? '').trim();
|
|
447
|
+
if (clean) {
|
|
448
|
+
const body = el('div', 'compact-text text');
|
|
449
|
+
body.innerHTML = renderMarkdown(clean);
|
|
450
|
+
card.append(body);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
container.appendChild(card);
|
|
454
|
+
return card;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Completa la tarjeta de compactación en curso con el resumen del modelo. */
|
|
458
|
+
export function finishCompactCard(card, text) {
|
|
459
|
+
if (!card) return;
|
|
460
|
+
card.classList.remove('running');
|
|
461
|
+
const rule = card.querySelector('.compact-rule');
|
|
462
|
+
if (rule) rule.textContent = 'contexto compactado';
|
|
463
|
+
const clean = String(text ?? '').trim();
|
|
464
|
+
if (clean && !card.querySelector('.compact-text')) {
|
|
465
|
+
const body = el('div', 'compact-text text');
|
|
466
|
+
body.innerHTML = renderMarkdown(clean);
|
|
467
|
+
card.append(body);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|