@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,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
INIT_PROMPT,
|
|
3
|
+
activePane,
|
|
4
|
+
addCompactCard,
|
|
5
|
+
addMessage,
|
|
6
|
+
basename,
|
|
7
|
+
chatInput,
|
|
8
|
+
finishCompactCard,
|
|
9
|
+
refreshSessions,
|
|
10
|
+
scrollMessages,
|
|
11
|
+
sendMessage,
|
|
12
|
+
} from './chat.js';
|
|
13
|
+
import {
|
|
14
|
+
activateTab,
|
|
15
|
+
createSessionIn,
|
|
16
|
+
cycleTheme,
|
|
17
|
+
openAppearance,
|
|
18
|
+
openFolderPicker,
|
|
19
|
+
openSettingsModal,
|
|
20
|
+
setSidebarOpen,
|
|
21
|
+
toggleMode,
|
|
22
|
+
} from './chrome.js';
|
|
23
|
+
import { $, api, clear, el, state } from './core.js';
|
|
24
|
+
import { setExplorerOpen } from './explorer.js';
|
|
25
|
+
import { renderMarkdown } from './markdown.js';
|
|
26
|
+
import { openTab, syncInterruptBtn } from './sessions.js';
|
|
27
|
+
import { reproducirVoz } from './voice.js';
|
|
28
|
+
|
|
29
|
+
/* --- command palette (Ctrl/Cmd+K) ----------------------------------------- */
|
|
30
|
+
|
|
31
|
+
export const PALETTE_COMMANDS = [
|
|
32
|
+
{ id: 'new', label: 'Nueva sesión en otra carpeta', hint: '/new', run: () => openFolderPicker() },
|
|
33
|
+
{ id: 'init', label: 'Analizar el proyecto y crear AGENTS.md', hint: '/init', run: () => runSlash('init') },
|
|
34
|
+
{ id: 'resumen', label: 'Resumir la conversación', hint: '/resumen', run: () => runSlash('resumen') },
|
|
35
|
+
{ id: 'audioresumen', label: 'Resumen como audio', hint: '/audioresumen', run: () => runSlash('audioresumen') },
|
|
36
|
+
{ id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
|
|
37
|
+
{ id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
|
|
38
|
+
{ id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
|
|
39
|
+
{ id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => openSettingsModal() },
|
|
40
|
+
{ id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('providers') },
|
|
41
|
+
{ id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
|
|
42
|
+
{ id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
|
|
43
|
+
{ id: 'sidebar', label: 'Mostrar u ocultar sesiones', run: () => setSidebarOpen(!state.sidebarOpen) },
|
|
44
|
+
{ id: 'theme', label: 'Cambiar tema', run: () => cycleTheme() },
|
|
45
|
+
{ id: 'apariencia', label: 'Apariencia (tema, tipografía, tamaño)', run: () => openAppearance() },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const palette = {
|
|
49
|
+
modal: $('#palette-modal'),
|
|
50
|
+
input: $('#palette-input'),
|
|
51
|
+
list: $('#palette-list'),
|
|
52
|
+
items: [],
|
|
53
|
+
index: 0,
|
|
54
|
+
timer: null,
|
|
55
|
+
results: { sessions: [], messages: [] },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function runSlash(name) {
|
|
59
|
+
chatInput.value = `/${name}`;
|
|
60
|
+
sendMessage();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function paletteItems(query) {
|
|
64
|
+
const q = query.trim().toLowerCase();
|
|
65
|
+
const items = PALETTE_COMMANDS.filter(
|
|
66
|
+
(c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q) || (c.hint ?? '').toLowerCase().includes(q)
|
|
67
|
+
).map((c) => ({ label: c.label, hint: c.hint || 'comando', run: c.run }));
|
|
68
|
+
|
|
69
|
+
for (const session of palette.results.sessions) {
|
|
70
|
+
items.push({
|
|
71
|
+
label: session.title || '(sin título)',
|
|
72
|
+
hint: session.cwd ? basename(session.cwd) : 'sesión',
|
|
73
|
+
run: () => openTab(session.id),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (const message of palette.results.messages) {
|
|
77
|
+
items.push({
|
|
78
|
+
label: message.snippet || '(mensaje)',
|
|
79
|
+
hint: `${message.role} · ${message.title || ''}`,
|
|
80
|
+
run: () => {
|
|
81
|
+
openTab(message.sessionId);
|
|
82
|
+
setTimeout(() => document.getElementById(message.id)?.scrollIntoView({ block: 'center' }), 450);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return items;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function setPaletteIndex(index) {
|
|
90
|
+
const count = palette.items.length;
|
|
91
|
+
if (!count) {
|
|
92
|
+
palette.index = 0;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
palette.index = ((index % count) + count) % count;
|
|
96
|
+
[...palette.list.children].forEach((child, i) => child.classList.toggle('active', i === palette.index));
|
|
97
|
+
palette.list.children[palette.index]?.scrollIntoView({ block: 'nearest' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function renderPalette(query) {
|
|
101
|
+
palette.items = paletteItems(query);
|
|
102
|
+
palette.list.replaceChildren();
|
|
103
|
+
if (!palette.items.length) {
|
|
104
|
+
palette.list.appendChild(el('li', 'palette-empty', 'sin resultados'));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
palette.items.forEach((item, i) => {
|
|
108
|
+
const li = el('li', 'palette-item');
|
|
109
|
+
const main = el('div', 'palette-item-main');
|
|
110
|
+
main.appendChild(el('span', 'palette-item-label', item.label));
|
|
111
|
+
if (item.hint) main.appendChild(el('span', 'palette-item-hint', item.hint));
|
|
112
|
+
li.appendChild(main);
|
|
113
|
+
li.addEventListener('mousemove', () => setPaletteIndex(i));
|
|
114
|
+
li.addEventListener('click', () => runPaletteItem(item));
|
|
115
|
+
palette.list.appendChild(li);
|
|
116
|
+
});
|
|
117
|
+
setPaletteIndex(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function runPaletteItem(item) {
|
|
121
|
+
palette.modal?.close();
|
|
122
|
+
setTimeout(() => item.run(), 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function openPalette() {
|
|
126
|
+
if (!palette.modal) return;
|
|
127
|
+
palette.input.value = '';
|
|
128
|
+
palette.results = { sessions: [], messages: [] };
|
|
129
|
+
renderPalette('');
|
|
130
|
+
palette.modal.showModal();
|
|
131
|
+
palette.input.focus();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* --- slash commands ------------------------------------------------------- */
|
|
135
|
+
|
|
136
|
+
export const COMMANDS = [
|
|
137
|
+
['/new', 'nueva sesión (elegís carpeta)'],
|
|
138
|
+
['/init', 'analizar el proyecto y crear/actualizar AGENTS.md'],
|
|
139
|
+
['/goal <texto>', 'fijar el objetivo de la sesión'],
|
|
140
|
+
['/resumen', 'resumir la conversación (sin borrarla)'],
|
|
141
|
+
['/audioresumen', 'el resumen como audio'],
|
|
142
|
+
['/compact', 'resumir la conversación y liberar contexto'],
|
|
143
|
+
['/clear', 'borrar los mensajes de esta sesión'],
|
|
144
|
+
['/cwd', 'mostrar la carpeta de trabajo'],
|
|
145
|
+
['/port <puerto>', 'ver si un puerto está activo (y qué PID lo usa)'],
|
|
146
|
+
['/portkill <puerto>', 'matar el proceso que escucha en un puerto'],
|
|
147
|
+
['/help', 'mostrar esta ayuda'],
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
export function notify(markdown, kind = 'notice') {
|
|
151
|
+
const message = addMessage('assistant', '');
|
|
152
|
+
// `kind` puede traer varias clases ("notice err"): classList.add no acepta
|
|
153
|
+
// espacios, asi que se agregan una por una.
|
|
154
|
+
for (const cls of String(kind).split(/\s+/).filter(Boolean)) message.textEl.classList.add(cls);
|
|
155
|
+
message.textEl.innerHTML = renderMarkdown(markdown);
|
|
156
|
+
scrollMessages();
|
|
157
|
+
return message;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Aviso flotante para eventos de fondo (un permiso en otra pestana, un run que
|
|
162
|
+
* arranco desde el celular).
|
|
163
|
+
*
|
|
164
|
+
* NO usar `notify` para esto: esa escribe un mensaje en la conversacion del pane
|
|
165
|
+
* activo, asi que un permiso caido en otra pestana terminaba-chatrandeando en
|
|
166
|
+
* el chat que estabas leyendo.
|
|
167
|
+
*/
|
|
168
|
+
export function toast(text) {
|
|
169
|
+
const host = $('#toasts');
|
|
170
|
+
if (!host) return;
|
|
171
|
+
const item = el('div', 'toast', text);
|
|
172
|
+
host.appendChild(item);
|
|
173
|
+
const timer = setTimeout(() => item.remove(), 6000);
|
|
174
|
+
item.addEventListener('click', () => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
item.remove();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function needSession() {
|
|
181
|
+
notify('Abrí o creá una sesión primero (`/new`).', 'notice err');
|
|
182
|
+
return { handled: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function runCommand(name, arg) {
|
|
186
|
+
switch (name) {
|
|
187
|
+
case 'help':
|
|
188
|
+
case 'ayuda':
|
|
189
|
+
notify(`### Comandos\n${COMMANDS.map(([cmd, desc]) => `- \`${cmd}\` — ${desc}`).join('\n')}`);
|
|
190
|
+
return { handled: true };
|
|
191
|
+
case 'new':
|
|
192
|
+
case 'nueva': {
|
|
193
|
+
const cwd = state.sessionId ? state.cwd : '';
|
|
194
|
+
if (cwd) {
|
|
195
|
+
const created = await createSessionIn(cwd);
|
|
196
|
+
if (created?.error) notify(`No se pudo crear la sesión: ${created.error}`, 'notice err');
|
|
197
|
+
} else {
|
|
198
|
+
openFolderPicker();
|
|
199
|
+
}
|
|
200
|
+
return { handled: true };
|
|
201
|
+
}
|
|
202
|
+
case 'init':
|
|
203
|
+
return { handled: false, input: INIT_PROMPT };
|
|
204
|
+
case 'cwd': {
|
|
205
|
+
const cwd = $('#rail-cwd').textContent;
|
|
206
|
+
notify(`Carpeta de trabajo: \`${cwd}\``);
|
|
207
|
+
return { handled: true };
|
|
208
|
+
}
|
|
209
|
+
case 'portkill':
|
|
210
|
+
case 'kill': {
|
|
211
|
+
const port = Number(arg);
|
|
212
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
213
|
+
notify('Uso: `/portkill <puerto>`, por ejemplo `/portkill 3000`.', 'notice err');
|
|
214
|
+
return { handled: true };
|
|
215
|
+
}
|
|
216
|
+
const out = await api('/api/ports/kill', { method: 'POST', body: { port } }).catch((error) => ({
|
|
217
|
+
error: error.message,
|
|
218
|
+
}));
|
|
219
|
+
if (out.error) notify(`No se pudo matar el puerto ${port}: ${out.error}`, 'notice err');
|
|
220
|
+
else if (!out.pids.length) notify(`No hay ningún proceso escuchando en el puerto ${port}.`);
|
|
221
|
+
else if (out.killed.length) notify(`Puerto ${port}: maté el PID ${out.killed.join(', ')}.`);
|
|
222
|
+
else notify(`Puerto ${port}: no se pudo matar (PID ${out.errors.join(', ')}).`, 'notice err');
|
|
223
|
+
return { handled: true };
|
|
224
|
+
}
|
|
225
|
+
case 'port': {
|
|
226
|
+
const port = Number(arg);
|
|
227
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
228
|
+
notify('Uso: `/port <puerto>`, por ejemplo `/port 3000`.', 'notice err');
|
|
229
|
+
return { handled: true };
|
|
230
|
+
}
|
|
231
|
+
const out = await api(`/api/ports/${port}`).catch((error) => ({ error: error.message }));
|
|
232
|
+
if (out.error) notify(`No se pudo consultar el puerto ${port}: ${out.error}`, 'notice err');
|
|
233
|
+
else if (out.listening) notify(`Puerto ${port}: **activo** (PID ${out.pids.join(', ')}).`);
|
|
234
|
+
else notify(`Puerto ${port}: nada escuchando.`);
|
|
235
|
+
return { handled: true };
|
|
236
|
+
}
|
|
237
|
+
case 'clear':
|
|
238
|
+
case 'limpiar': {
|
|
239
|
+
if (!state.sessionId) return needSession();
|
|
240
|
+
await api(`/api/sessions/${state.sessionId}/messages`, { method: 'DELETE' }).catch(() => null);
|
|
241
|
+
clear(activePane);
|
|
242
|
+
activePane.appendChild(el('div', 'empty', 'Sesión vacía.'));
|
|
243
|
+
return { handled: true };
|
|
244
|
+
}
|
|
245
|
+
case 'goal': {
|
|
246
|
+
if (!state.sessionId) return needSession();
|
|
247
|
+
if (!arg) {
|
|
248
|
+
const data = await api(`/api/sessions/${state.sessionId}`).catch(() => null);
|
|
249
|
+
notify(data?.session?.goal ? `Objetivo actual: **${data.session.goal}**` : 'No hay objetivo fijado.');
|
|
250
|
+
return { handled: true };
|
|
251
|
+
}
|
|
252
|
+
await api(`/api/sessions/${state.sessionId}`, { method: 'PUT', body: { goal: arg } }).catch(() => null);
|
|
253
|
+
notify(`Objetivo de la sesión: **${arg}**`);
|
|
254
|
+
return { handled: true };
|
|
255
|
+
}
|
|
256
|
+
case 'resumen':
|
|
257
|
+
case 'resume': {
|
|
258
|
+
if (!state.sessionId) return needSession();
|
|
259
|
+
notify('Resumiendo la conversación…');
|
|
260
|
+
const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
|
|
261
|
+
error: error.message,
|
|
262
|
+
}));
|
|
263
|
+
if (out.error) notify(`No se pudo resumir: ${out.error}`, 'notice err');
|
|
264
|
+
else notify(`**Resumen**\n\n${out.summary}`);
|
|
265
|
+
return { handled: true };
|
|
266
|
+
}
|
|
267
|
+
case 'audioresumen':
|
|
268
|
+
case 'audio-resumen': {
|
|
269
|
+
if (!state.sessionId) return needSession();
|
|
270
|
+
notify('Preparando el resumen…');
|
|
271
|
+
const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
|
|
272
|
+
error: error.message,
|
|
273
|
+
}));
|
|
274
|
+
if (out.error) {
|
|
275
|
+
notify(`No se pudo resumir: ${out.error}`, 'notice err');
|
|
276
|
+
return { handled: true };
|
|
277
|
+
}
|
|
278
|
+
notify(`**Resumen**\n\n${out.summary}`);
|
|
279
|
+
reproducirVoz(out.summary, { label: 'resumen' }).catch((error) => notify(`No se pudo reproducir el audio: ${error.message}`, 'notice err'));
|
|
280
|
+
return { handled: true };
|
|
281
|
+
}
|
|
282
|
+
case 'compact': {
|
|
283
|
+
if (!state.sessionId) return needSession();
|
|
284
|
+
// Animación en el pane: el corte queda a la vista con un loader mientras
|
|
285
|
+
// el modelo resume (puede tardar), y se completa con el resumen.
|
|
286
|
+
const card = addCompactCard(activePane, { running: true });
|
|
287
|
+
scrollMessages();
|
|
288
|
+
const out = await api(`/api/sessions/${state.sessionId}/compact`, { method: 'POST' }).catch((error) => ({
|
|
289
|
+
error: error.message,
|
|
290
|
+
}));
|
|
291
|
+
if (out.error) {
|
|
292
|
+
card?.remove();
|
|
293
|
+
notify(`No se pudo compactar: ${out.error}`, 'notice err');
|
|
294
|
+
return { handled: true };
|
|
295
|
+
}
|
|
296
|
+
// No borramos el pane: el historial queda a la vista y marcamos el corte
|
|
297
|
+
// con la línea + el resumen que ahora ve el modelo.
|
|
298
|
+
finishCompactCard(card, out.summary);
|
|
299
|
+
scrollMessages();
|
|
300
|
+
refreshSessions();
|
|
301
|
+
return { handled: true };
|
|
302
|
+
}
|
|
303
|
+
default:
|
|
304
|
+
notify(`Comando desconocido: \`/${name}\`. Probá \`/help\`.`, 'notice err');
|
|
305
|
+
return { handled: true };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* --- menú de comandos "/" ------------------------------------------------- */
|
|
310
|
+
|
|
311
|
+
export const slashMenuEl = $('#slash-menu');
|
|
312
|
+
export let slashMatches = [];
|
|
313
|
+
export let slashIndex = 0;
|
|
314
|
+
|
|
315
|
+
export function slashCommandEntries() {
|
|
316
|
+
return COMMANDS.map(([cmd, desc]) => {
|
|
317
|
+
const name = cmd.split(' ')[0];
|
|
318
|
+
const takesArgs = cmd.includes(' ');
|
|
319
|
+
return { name, desc, insert: takesArgs ? `${name} ` : name, takesArgs };
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function hideSlashMenu() {
|
|
324
|
+
if (!slashMenuEl) return;
|
|
325
|
+
slashMenuEl.hidden = true;
|
|
326
|
+
slashMenuEl.replaceChildren();
|
|
327
|
+
slashMatches = [];
|
|
328
|
+
slashIndex = 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function renderSlashMenu() {
|
|
332
|
+
if (!slashMenuEl) return;
|
|
333
|
+
slashMenuEl.replaceChildren();
|
|
334
|
+
slashMatches.forEach((entry, index) => {
|
|
335
|
+
const item = el('button', `slash-item${index === slashIndex ? ' active' : ''}`);
|
|
336
|
+
item.type = 'button';
|
|
337
|
+
item.append(el('span', 'slash-cmd', entry.name), el('span', 'slash-desc', entry.desc));
|
|
338
|
+
// mousedown (no click) para no perder el foco del textarea antes de elegir.
|
|
339
|
+
item.addEventListener('mousedown', (event) => {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
chooseSlash(index);
|
|
342
|
+
});
|
|
343
|
+
slashMenuEl.appendChild(item);
|
|
344
|
+
});
|
|
345
|
+
slashMenuEl.hidden = false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function updateSlashMenu() {
|
|
349
|
+
if (!slashMenuEl) return;
|
|
350
|
+
const value = chatInput.value;
|
|
351
|
+
if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
|
|
352
|
+
const query = value.slice(1).toLowerCase();
|
|
353
|
+
slashMatches = slashCommandEntries().filter(
|
|
354
|
+
(entry) =>
|
|
355
|
+
entry.name.slice(1).toLowerCase().startsWith(query) || entry.desc.toLowerCase().includes(query)
|
|
356
|
+
);
|
|
357
|
+
if (!slashMatches.length) return hideSlashMenu();
|
|
358
|
+
if (slashIndex >= slashMatches.length) slashIndex = 0;
|
|
359
|
+
renderSlashMenu();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function chooseSlash(index) {
|
|
363
|
+
const entry = slashMatches[index];
|
|
364
|
+
if (!entry) return;
|
|
365
|
+
chatInput.value = entry.insert;
|
|
366
|
+
chatInput.focus();
|
|
367
|
+
hideSlashMenu();
|
|
368
|
+
if (!entry.takesArgs) sendMessage();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function handleSlashKey(event) {
|
|
372
|
+
if (!slashMenuEl || slashMenuEl.hidden || !slashMatches.length) return false;
|
|
373
|
+
if (event.key === 'ArrowDown') {
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
slashIndex = (slashIndex + 1) % slashMatches.length;
|
|
376
|
+
renderSlashMenu();
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
if (event.key === 'ArrowUp') {
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
slashIndex = (slashIndex - 1 + slashMatches.length) % slashMatches.length;
|
|
382
|
+
renderSlashMenu();
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
if (event.key === 'Escape') {
|
|
386
|
+
event.preventDefault();
|
|
387
|
+
hideSlashMenu();
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
chooseSlash(slashIndex);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function onChatInputChanged() {
|
|
399
|
+
slashIndex = 0;
|
|
400
|
+
updateSlashMenu();
|
|
401
|
+
syncInterruptBtn();
|
|
402
|
+
}
|
|
403
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { streamSse } from './runStream.js';
|
|
2
|
+
|
|
3
|
+
let csrfToken = '';
|
|
4
|
+
|
|
5
|
+
/** El token CSRF lo fija `boot()` tras leer `/api/auth/me`. */
|
|
6
|
+
export function setCsrfToken(token) {
|
|
7
|
+
csrfToken = token || '';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const $ = (sel) => document.querySelector(sel);
|
|
11
|
+
|
|
12
|
+
export const state = {
|
|
13
|
+
sessionId: null,
|
|
14
|
+
streaming: false,
|
|
15
|
+
providers: [],
|
|
16
|
+
active: { provider: '', model: '' },
|
|
17
|
+
pendingInput: null,
|
|
18
|
+
permissionCards: new Map(),
|
|
19
|
+
questionCards: new Map(),
|
|
20
|
+
attachments: [],
|
|
21
|
+
home: null,
|
|
22
|
+
cwd: '',
|
|
23
|
+
explorerOpen: false,
|
|
24
|
+
sidebarOpen: true,
|
|
25
|
+
panes: new Map(),
|
|
26
|
+
tabOrder: [],
|
|
27
|
+
activeTab: null,
|
|
28
|
+
queues: new Map(),
|
|
29
|
+
// Grupos de sesiones abiertos a mano. Arranca vacio y NO se persiste: el
|
|
30
|
+
// sidebar siempre empieza con todas las carpetas sin expandir.
|
|
31
|
+
expandedGroups: new Set(),
|
|
32
|
+
// Sesiones con un run vivo segun el canal global de eventos. El sidebar lo
|
|
33
|
+
// usa para el punto de actividad antes de abrir la sesion.
|
|
34
|
+
remoteActive: new Set(),
|
|
35
|
+
// Permisos esperando decision: askId -> { sessionId, name, args }. Vive fuera
|
|
36
|
+
// del stream a proposito: un cliente sin stream abierto igual tiene que poder
|
|
37
|
+
// ver que la IA esta esperando una autorizacion suya.
|
|
38
|
+
pendingAsks: new Map(),
|
|
39
|
+
// Formularios interactivos (`ask_user`) esperando respuesta: askId -> { sessionId }.
|
|
40
|
+
pendingQuestions: new Map(),
|
|
41
|
+
// Ponerlo en cola para reconciliar apenas termine la que esta en curso.
|
|
42
|
+
reconcileAgain: false,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/* --- DOM helpers --------------------------------------------------------- */
|
|
46
|
+
|
|
47
|
+
export function el(tag, className = '', text = '') {
|
|
48
|
+
const node = document.createElement(tag);
|
|
49
|
+
if (className) node.className = className;
|
|
50
|
+
if (text) node.textContent = text;
|
|
51
|
+
return node;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function button(label, onClick, className = 'btn') {
|
|
55
|
+
const node = el('button', className, label);
|
|
56
|
+
node.type = 'button';
|
|
57
|
+
node.addEventListener('click', onClick);
|
|
58
|
+
return node;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clear(node) {
|
|
62
|
+
node.replaceChildren();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const ICON_PATHS = {
|
|
66
|
+
x: ['M18 6 6 18', 'm6 6 12 12'],
|
|
67
|
+
'chevron-right': ['m9 18 6-6-6-6'],
|
|
68
|
+
'chevron-down': ['m6 9 6 6 6-6'],
|
|
69
|
+
folder: ['M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'],
|
|
70
|
+
'folder-open': ['m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2'],
|
|
71
|
+
file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
|
|
72
|
+
'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
|
|
73
|
+
undo: ['M3 7v6h6', 'M3 13a9 9 0 1 1 2.64 6.36'],
|
|
74
|
+
trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
|
|
75
|
+
'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
|
|
76
|
+
'dots-horizontal': ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
|
|
77
|
+
play: ['m6 4 14 8-14 8z'],
|
|
78
|
+
pause: ['M8 5v14', 'M16 5v14'],
|
|
79
|
+
square: ['M6 6h12v12H6z'],
|
|
80
|
+
copy: ['M8 8V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3', 'M5 8h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z'],
|
|
81
|
+
pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z', 'm15 5 4 4'],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function icon(name, size = 16, className = '') {
|
|
85
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
86
|
+
if (className) svg.setAttribute('class', className);
|
|
87
|
+
for (const [key, value] of Object.entries({ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', width: String(size), height: String(size), 'aria-hidden': 'true' })) {
|
|
88
|
+
svg.setAttribute(key, value);
|
|
89
|
+
}
|
|
90
|
+
for (const d of ICON_PATHS[name] ?? []) {
|
|
91
|
+
const node = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
92
|
+
node.setAttribute('d', d);
|
|
93
|
+
svg.appendChild(node);
|
|
94
|
+
}
|
|
95
|
+
return svg;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Confirmacion accesible (reemplaza `window.confirm`): `<dialog>` modal con
|
|
100
|
+
* Escape, foco inicial en cancelar y respuesta booleana.
|
|
101
|
+
*/
|
|
102
|
+
export function showConfirm(
|
|
103
|
+
message,
|
|
104
|
+
{ title = 'Confirmar', confirmLabel = 'aceptar', cancelLabel = 'cancelar', danger = false } = {}
|
|
105
|
+
) {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
const dialog = document.createElement('dialog');
|
|
108
|
+
dialog.className = 'modal confirm-modal';
|
|
109
|
+
dialog.setAttribute('aria-label', title);
|
|
110
|
+
const card = el('div', 'modal-card');
|
|
111
|
+
card.append(el('h3', '', title), el('p', 'confirm-text', message));
|
|
112
|
+
const actions = el('div', 'confirm-actions');
|
|
113
|
+
const cancel = button(cancelLabel, () => dialog.close('cancel'), 'btn');
|
|
114
|
+
const ok = button(confirmLabel, () => dialog.close('confirm'), danger ? 'btn danger' : 'btn primary');
|
|
115
|
+
actions.append(cancel, ok);
|
|
116
|
+
card.append(actions);
|
|
117
|
+
dialog.append(card);
|
|
118
|
+
dialog.addEventListener('close', () => {
|
|
119
|
+
resolve(dialog.returnValue === 'confirm');
|
|
120
|
+
dialog.remove();
|
|
121
|
+
});
|
|
122
|
+
document.body.append(dialog);
|
|
123
|
+
dialog.showModal();
|
|
124
|
+
cancel.focus();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* --- HTTP / SSE ---------------------------------------------------------- */
|
|
129
|
+
|
|
130
|
+
// La cookie de sesion puede vencer con la pestana abierta: sin este interceptor
|
|
131
|
+
// la UI mostraba tostadas genericas en cada accion. Un 401 = a la pantalla de
|
|
132
|
+
// acceso, una sola vez.
|
|
133
|
+
let sessionExpired = false;
|
|
134
|
+
|
|
135
|
+
export async function api(path, { method = 'GET', body, headers = {} } = {}) {
|
|
136
|
+
const init = {};
|
|
137
|
+
if (body) init['Content-Type'] = 'application/json';
|
|
138
|
+
if (method !== 'GET' && csrfToken) init['x-csrf'] = csrfToken;
|
|
139
|
+
const res = await fetch(path, {
|
|
140
|
+
method,
|
|
141
|
+
headers: { ...init, ...headers },
|
|
142
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
let message = res.statusText;
|
|
146
|
+
try {
|
|
147
|
+
message = (await res.json()).error ?? message;
|
|
148
|
+
} catch {
|
|
149
|
+
// keep statusText
|
|
150
|
+
}
|
|
151
|
+
const error = new Error(message);
|
|
152
|
+
// El status importa: un 409 significa que otro cliente tiene el turno.
|
|
153
|
+
error.status = res.status;
|
|
154
|
+
if (res.status === 401 && !sessionExpired) {
|
|
155
|
+
sessionExpired = true;
|
|
156
|
+
location.assign('/landing');
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
return res.json();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const streamPost = (url, body, onEvent, signal) =>
|
|
164
|
+
streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
|
|
165
|
+
|
|
166
|
+
/** SSE GET de la consola (/api/sessions/:id/stream, /api/events). */
|
|
167
|
+
export const openStream = (url, opts, onEvent) => streamSse(url, opts, onEvent);
|
|
168
|
+
|
|
169
|
+
/* --- localStorage (todas las lecturas/escrituras pasan por aca) ------------ */
|
|
170
|
+
|
|
171
|
+
export const storage = {
|
|
172
|
+
get(key, fallback = null) {
|
|
173
|
+
try {
|
|
174
|
+
const raw = localStorage.getItem(key);
|
|
175
|
+
return raw === null ? fallback : raw;
|
|
176
|
+
} catch {
|
|
177
|
+
return fallback;
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
getJson(key, fallback) {
|
|
181
|
+
try {
|
|
182
|
+
const raw = localStorage.getItem(key);
|
|
183
|
+
return raw === null ? fallback : JSON.parse(raw);
|
|
184
|
+
} catch {
|
|
185
|
+
return fallback;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
set(key, value) {
|
|
189
|
+
try {
|
|
190
|
+
localStorage.setItem(key, value);
|
|
191
|
+
} catch {
|
|
192
|
+
// storage unavailable
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
setJson(key, value) {
|
|
196
|
+
try {
|
|
197
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
198
|
+
} catch {
|
|
199
|
+
// storage unavailable
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
remove(key) {
|
|
203
|
+
try {
|
|
204
|
+
localStorage.removeItem(key);
|
|
205
|
+
} catch {
|
|
206
|
+
// storage unavailable
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
};
|